Allow project maintainers to register webhook URLs that receive HTTP
POST notifications when patchwork events occur (patch created, series
completed, comments, state changes, etc.).

Webhooks are delivered synchronously in signal handlers right after
event creation. Payloads are signed with HMAC-SHA256 using a
per-webhook secret. The new endpoints are available under API v1.4
at /api/1.4/projects/{id}/webhooks/.

Register the Webhook model in the admin interface instead of building
a custom web UI. The admin already provides CRUD, filtering and search
out of the box.

This is the first step towards enabling patchwork as a bridge between
mailing list and code forge workflows.

Signed-off-by: Robin Jarry <[email protected]>
---
 docs/api/schemas/latest/patchwork.yaml        | 328 +++++++++++++++++
 docs/api/schemas/patchwork.j2                 | 338 ++++++++++++++++++
 docs/api/schemas/v1.4/patchwork.yaml          | 328 +++++++++++++++++
 patchwork/admin.py                            |  49 +++
 patchwork/api/webhook.py                      |  76 ++++
 patchwork/migrations/0049_webhook.py          |  78 ++++
 patchwork/models.py                           |  42 +++
 patchwork/signals.py                          |  27 +-
 patchwork/tests/unit/api/test_webhook.py      | 251 +++++++++++++
 patchwork/tests/utils.py                      |  15 +
 patchwork/urls.py                             |  18 +-
 patchwork/webhooks.py                         |  88 +++++
 .../webhook-support-a1b2c3d4e5f6g7h8.yaml     |  13 +
 13 files changed, 1638 insertions(+), 13 deletions(-)
 create mode 100644 patchwork/api/webhook.py
 create mode 100644 patchwork/migrations/0049_webhook.py
 create mode 100644 patchwork/tests/unit/api/test_webhook.py
 create mode 100644 patchwork/webhooks.py
 create mode 100644 releasenotes/notes/webhook-support-a1b2c3d4e5f6g7h8.yaml

diff --git a/docs/api/schemas/latest/patchwork.yaml 
b/docs/api/schemas/latest/patchwork.yaml
index b2bb220fd28b..eb7dcd382fc8 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -1153,6 +1153,228 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - projects
+  /api/projects/{project_id}/webhooks:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+    get:
+      summary: List webhooks.
+      description: |
+        List all webhooks for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_list
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: 'List of webhooks'
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    post:
+      summary: Create a webhook.
+      description: |
+        Create a new webhook for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_create
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '201':
+          description: 'Created webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+  /api/projects/{project_id}/webhooks/{webhook_id}:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+      - in: path
+        name: webhook_id
+        description: A unique integer value identifying this webhook.
+        required: true
+        schema:
+          title: Webhook ID
+          type: integer
+    get:
+      summary: Show a webhook.
+      description: |
+        Retrieve a webhook by its ID.
+        You must be a maintainer of the project.
+      operationId: webhooks_read
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '200':
+          description: 'A webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    patch:
+      summary: Update a webhook (partial).
+      description:
+        Partially update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    put:
+      summary: Update a webhook.
+      description:
+        Update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    delete:
+      summary: Delete a webhook.
+      description:
+        Delete an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: 'Deleted webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
   /api/series:
     get:
       summary: List series.
@@ -1478,6 +1700,14 @@ components:
         application/json:
           schema:
             $ref: '#/components/schemas/CommentUpdate'
+    Webhook:
+      required: true
+      description: |
+        A webhook.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/WebhookCreateUpdate'
     Patch:
       required: true
       description: |
@@ -2689,6 +2919,72 @@ components:
                     Show click-to-copy IDs in the list view (web UI).
                     Only present and configurable for your account.
                   type: boolean
+    Webhook:
+      type: object
+      title: Webhook
+      description: |
+        A webhook
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          writeOnly: true
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
+        creator:
+          $ref: '#/components/schemas/UserEmbedded'
+        created:
+          title: Created
+          type: string
+          format: iso8601
+          readOnly: true
+    WebhookCreateUpdate:
+      type: object
+      title: Webhook create or update
+      description: |
+        The fields to set on a new or existing webhook.
+      required:
+        - url
+      properties:
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
     CheckEmbedded:
       type: object
       title: Check
@@ -3153,6 +3449,36 @@ components:
           type: array
           items:
             type: string
+    ErrorWebhookCreateUpdate:
+      type: object
+      title: A webhook creation or update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        url:
+          title: URL
+          type: array
+          items:
+            type: string
+          readOnly: true
+        secret:
+          title: Secret
+          type: array
+          items:
+            type: string
+          readOnly: true
+        events:
+          title: Events
+          type: array
+          items:
+            type: string
+          readOnly: true
+        active:
+          title: Active
+          type: array
+          items:
+            type: string
+          readOnly: true
     ErrorPatchUpdate:
       type: object
       title: A patch update error.
@@ -3243,3 +3569,5 @@ tags:
     description: Check operations
   - name: events
     description: Event operations
+  - name: webhooks
+    description: Webhook operations
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index f37d3213e33b..56c72158e0b2 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -1178,6 +1178,230 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - projects
+{% if version >= (1, 4) %}
+  /api/{{ version_url }}projects/{project_id}/webhooks:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+    get:
+      summary: List webhooks.
+      description: |
+        List all webhooks for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_list
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: 'List of webhooks'
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    post:
+      summary: Create a webhook.
+      description: |
+        Create a new webhook for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_create
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '201':
+          description: 'Created webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+  /api/{{ version_url }}projects/{project_id}/webhooks/{webhook_id}:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+      - in: path
+        name: webhook_id
+        description: A unique integer value identifying this webhook.
+        required: true
+        schema:
+          title: Webhook ID
+          type: integer
+    get:
+      summary: Show a webhook.
+      description: |
+        Retrieve a webhook by its ID.
+        You must be a maintainer of the project.
+      operationId: webhooks_read
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '200':
+          description: 'A webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    patch:
+      summary: Update a webhook (partial).
+      description:
+        Partially update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    put:
+      summary: Update a webhook.
+      description:
+        Update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    delete:
+      summary: Delete a webhook.
+      description:
+        Delete an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: 'Deleted webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+{% endif %}
   /api/{{ version_url }}series:
     get:
       summary: List series.
@@ -1518,6 +1742,16 @@ components:
         application/json:
           schema:
             $ref: '#/components/schemas/CommentUpdate'
+{% endif %}
+{% if version >= (1, 4) %}
+    Webhook:
+      required: true
+      description: |
+        A webhook.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/WebhookCreateUpdate'
 {% endif %}
     Patch:
       required: true
@@ -2788,6 +3022,74 @@ components:
                     Show click-to-copy IDs in the list view (web UI).
                     Only present and configurable for your account.
                   type: boolean
+{% endif %}
+{% if version >= (1, 4) %}
+    Webhook:
+      type: object
+      title: Webhook
+      description: |
+        A webhook
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          writeOnly: true
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
+        creator:
+          $ref: '#/components/schemas/UserEmbedded'
+        created:
+          title: Created
+          type: string
+          format: iso8601
+          readOnly: true
+    WebhookCreateUpdate:
+      type: object
+      title: Webhook create or update
+      description: |
+        The fields to set on a new or existing webhook.
+      required:
+        - url
+      properties:
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
 {% endif %}
     CheckEmbedded:
       type: object
@@ -3274,6 +3576,38 @@ components:
           type: array
           items:
             type: string
+{% endif %}
+{% if version >= (1, 4) %}
+    ErrorWebhookCreateUpdate:
+      type: object
+      title: A webhook creation or update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        url:
+          title: URL
+          type: array
+          items:
+            type: string
+          readOnly: true
+        secret:
+          title: Secret
+          type: array
+          items:
+            type: string
+          readOnly: true
+        events:
+          title: Events
+          type: array
+          items:
+            type: string
+          readOnly: true
+        active:
+          title: Active
+          type: array
+          items:
+            type: string
+          readOnly: true
 {% endif %}
     ErrorPatchUpdate:
       type: object
@@ -3365,3 +3699,7 @@ tags:
     description: Check operations
   - name: events
     description: Event operations
+{%- if version >= (1, 4) +%}
+  - name: webhooks
+    description: Webhook operations
+{%- endif %}
diff --git a/docs/api/schemas/v1.4/patchwork.yaml 
b/docs/api/schemas/v1.4/patchwork.yaml
index 036fe15f1e81..d15bf24e000b 100644
--- a/docs/api/schemas/v1.4/patchwork.yaml
+++ b/docs/api/schemas/v1.4/patchwork.yaml
@@ -1153,6 +1153,228 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - projects
+  /api/1.4/projects/{project_id}/webhooks:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+    get:
+      summary: List webhooks.
+      description: |
+        List all webhooks for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_list
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      parameters:
+        - $ref: '#/components/parameters/Page'
+        - $ref: '#/components/parameters/PageSize'
+        - $ref: '#/components/parameters/Order'
+      responses:
+        '200':
+          description: 'List of webhooks'
+          headers:
+            Link:
+              $ref: '#/components/headers/Link'
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    post:
+      summary: Create a webhook.
+      description: |
+        Create a new webhook for the given project.
+        You must be a maintainer of the project.
+      operationId: webhooks_create
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '201':
+          description: 'Created webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+  /api/1.4/projects/{project_id}/webhooks/{webhook_id}:
+    parameters:
+      - in: path
+        name: project_id
+        description: A unique integer value identifying the project.
+        required: true
+        schema:
+          title: Project ID
+          type: integer
+      - in: path
+        name: webhook_id
+        description: A unique integer value identifying this webhook.
+        required: true
+        schema:
+          title: Webhook ID
+          type: integer
+    get:
+      summary: Show a webhook.
+      description: |
+        Retrieve a webhook by its ID.
+        You must be a maintainer of the project.
+      operationId: webhooks_read
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '200':
+          description: 'A webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    patch:
+      summary: Update a webhook (partial).
+      description:
+        Partially update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    put:
+      summary: Update a webhook.
+      description:
+        Update an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Webhook'
+      responses:
+        '200':
+          description: 'Updated webhook'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Webhook'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorWebhookCreateUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
+    delete:
+      summary: Delete a webhook.
+      description:
+        Delete an existing webhook.
+        You must be a maintainer of the project.
+      operationId: webhooks_delete
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      responses:
+        '204':
+          description: 'Deleted webhook'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - webhooks
   /api/1.4/series:
     get:
       summary: List series.
@@ -1478,6 +1700,14 @@ components:
         application/json:
           schema:
             $ref: '#/components/schemas/CommentUpdate'
+    Webhook:
+      required: true
+      description: |
+        A webhook.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/WebhookCreateUpdate'
     Patch:
       required: true
       description: |
@@ -2689,6 +2919,72 @@ components:
                     Show click-to-copy IDs in the list view (web UI).
                     Only present and configurable for your account.
                   type: boolean
+    Webhook:
+      type: object
+      title: Webhook
+      description: |
+        A webhook
+      properties:
+        id:
+          title: ID
+          type: integer
+          readOnly: true
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          writeOnly: true
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
+        creator:
+          $ref: '#/components/schemas/UserEmbedded'
+        created:
+          title: Created
+          type: string
+          format: iso8601
+          readOnly: true
+    WebhookCreateUpdate:
+      type: object
+      title: Webhook create or update
+      description: |
+        The fields to set on a new or existing webhook.
+      required:
+        - url
+      properties:
+        url:
+          title: URL
+          description: The URL to send webhook payloads to.
+          type: string
+          format: uri
+          maxLength: 500
+        secret:
+          title: Secret
+          description: Secret used to sign webhook payloads via HMAC-SHA256.
+          type: string
+          maxLength: 255
+        events:
+          title: Events
+          description: |
+            Comma-separated list of event categories, or * for all.
+          type: string
+          maxLength: 500
+        active:
+          title: Active
+          type: boolean
     CheckEmbedded:
       type: object
       title: Check
@@ -3153,6 +3449,36 @@ components:
           type: array
           items:
             type: string
+    ErrorWebhookCreateUpdate:
+      type: object
+      title: A webhook creation or update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        url:
+          title: URL
+          type: array
+          items:
+            type: string
+          readOnly: true
+        secret:
+          title: Secret
+          type: array
+          items:
+            type: string
+          readOnly: true
+        events:
+          title: Events
+          type: array
+          items:
+            type: string
+          readOnly: true
+        active:
+          title: Active
+          type: array
+          items:
+            type: string
+          readOnly: true
     ErrorPatchUpdate:
       type: object
       title: A patch update error.
@@ -3243,3 +3569,5 @@ tags:
     description: Check operations
   - name: events
     description: Event operations
+  - name: webhooks
+    description: Webhook operations
diff --git a/patchwork/admin.py b/patchwork/admin.py
index d3bdae1bbc23..26de8baefb36 100644
--- a/patchwork/admin.py
+++ b/patchwork/admin.py
@@ -3,6 +3,7 @@
 #
 # SPDX-License-Identifier: GPL-2.0-or-later
 
+from django import forms
 from django.contrib import admin
 from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
 from django.contrib.auth.models import User
@@ -13,6 +14,7 @@ from patchwork.models import Check
 from patchwork.models import Cover
 from patchwork.models import CoverComment
 from patchwork.models import DelegationRule
+from patchwork.models import Event
 from patchwork.models import Patch
 from patchwork.models import PatchComment
 from patchwork.models import PatchRelation
@@ -23,6 +25,7 @@ from patchwork.models import SeriesReference
 from patchwork.models import State
 from patchwork.models import Tag
 from patchwork.models import UserProfile
+from patchwork.models import Webhook
 
 
 class UserProfileInline(admin.StackedInline):
@@ -194,3 +197,49 @@ class TagAdmin(admin.ModelAdmin):
 @admin.register(PatchRelation)
 class PatchRelationAdmin(admin.ModelAdmin):
     model = PatchRelation
+
+
+class WebhookForm(forms.ModelForm):
+    ALL_EVENTS = '*'
+
+    event_select = forms.MultipleChoiceField(
+        choices=Event.CATEGORY_CHOICES,
+        widget=forms.CheckboxSelectMultiple,
+        required=False,
+        label='Events',
+        help_text='Select which events trigger this webhook. '
+        'Leave empty to receive all events.',
+    )
+
+    class Meta:
+        model = Webhook
+        fields = ('project', 'url', 'secret', 'active')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if self.instance.pk:
+            events = self.instance.events
+            if events != self.ALL_EVENTS:
+                self.initial['event_select'] = events.split(',')
+
+    def save(self, commit=True):
+        selected = self.cleaned_data.get('event_select', [])
+        if selected:
+            self.instance.events = ','.join(selected)
+        else:
+            self.instance.events = self.ALL_EVENTS
+        return super().save(commit)
+
+
[email protected](Webhook)
+class WebhookAdmin(admin.ModelAdmin):
+    form = WebhookForm
+    list_display = ('project', 'url', 'events', 'active', 'creator', 'created')
+    list_filter = ('project', 'active')
+    search_fields = ('url', 'project__name')
+    readonly_fields = ('created',)
+
+    def save_model(self, request, obj, form, change):
+        if not change:
+            obj.creator = request.user
+        super().save_model(request, obj, form, change)
diff --git a/patchwork/api/webhook.py b/patchwork/api/webhook.py
new file mode 100644
index 000000000000..7ee2c5a601c2
--- /dev/null
+++ b/patchwork/api/webhook.py
@@ -0,0 +1,76 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <[email protected]>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from rest_framework.generics import ListCreateAPIView
+from rest_framework.generics import RetrieveUpdateDestroyAPIView
+from rest_framework import permissions
+from rest_framework.serializers import ModelSerializer
+
+from patchwork.models import Event
+from patchwork.models import Project
+from patchwork.models import Webhook
+
+
+class IsProjectMaintainer(permissions.BasePermission):
+    def has_permission(self, request, view):
+        if not request.user or not request.user.is_authenticated:
+            return False
+        project_id = view.kwargs.get('project_id')
+        return request.user.profile.maintainer_projects.filter(
+            pk=project_id
+        ).exists()
+
+
+class WebhookSerializer(ModelSerializer):
+    class Meta:
+        model = Webhook
+        fields = (
+            'id',
+            'url',
+            'secret',
+            'events',
+            'active',
+            'creator',
+            'created',
+        )
+        read_only_fields = ('id', 'creator', 'created')
+        extra_kwargs = {
+            'secret': {'write_only': True},
+        }
+
+    def validate_events(self, value):
+        if value == '*':
+            return value
+        valid = {c[0] for c in Event.CATEGORY_CHOICES}
+        for cat in value.split(','):
+            cat = cat.strip()
+            if cat not in valid:
+                from rest_framework.exceptions import ValidationError
+
+                raise ValidationError(
+                    "Invalid event category '%s'. Valid categories: %s"
+                    % (cat, ', '.join(sorted(valid)))
+                )
+        return value
+
+
+class WebhookList(ListCreateAPIView):
+    permission_classes = (IsProjectMaintainer,)
+    serializer_class = WebhookSerializer
+
+    def get_queryset(self):
+        return Webhook.objects.filter(project_id=self.kwargs['project_id'])
+
+    def perform_create(self, serializer):
+        project = Project.objects.get(pk=self.kwargs['project_id'])
+        serializer.save(project=project, creator=self.request.user)
+
+
+class WebhookDetail(RetrieveUpdateDestroyAPIView):
+    permission_classes = (IsProjectMaintainer,)
+    serializer_class = WebhookSerializer
+
+    def get_queryset(self):
+        return Webhook.objects.filter(project_id=self.kwargs['project_id'])
diff --git a/patchwork/migrations/0049_webhook.py 
b/patchwork/migrations/0049_webhook.py
new file mode 100644
index 000000000000..5bdd9ebda45d
--- /dev/null
+++ b/patchwork/migrations/0049_webhook.py
@@ -0,0 +1,78 @@
+# Generated by Django 5.1.15 on 2026-05-25 02:49
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('patchwork', '0048_series_dependencies'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Webhook',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name='ID',
+                    ),
+                ),
+                (
+                    'url',
+                    models.URLField(
+                        help_text='The URL to send webhook payloads to.',
+                        max_length=500,
+                    ),
+                ),
+                (
+                    'secret',
+                    models.CharField(
+                        blank=True,
+                        default='',
+                        help_text='Secret used to sign webhook payloads via 
HMAC-SHA256.',
+                        max_length=255,
+                    ),
+                ),
+                (
+                    'events',
+                    models.CharField(
+                        default='*',
+                        help_text='Comma-separated list of event categories, 
or * for all.',
+                        max_length=500,
+                    ),
+                ),
+                ('active', models.BooleanField(default=True)),
+                (
+                    'created',
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                (
+                    'creator',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='+',
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                (
+                    'project',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='webhooks',
+                        to='patchwork.project',
+                    ),
+                ),
+            ],
+            options={
+                'unique_together': {('project', 'url')},
+            },
+        ),
+    ]
diff --git a/patchwork/models.py b/patchwork/models.py
index d5cb31de9568..ed89aa7e26c7 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -1335,6 +1335,48 @@ class Event(models.Model):
         ordering = ['-date']
 
 
+class Webhook(models.Model):
+    project = models.ForeignKey(
+        Project,
+        related_name='webhooks',
+        on_delete=models.CASCADE,
+    )
+    url = models.URLField(
+        max_length=500,
+        help_text='The URL to send webhook payloads to.',
+    )
+    secret = models.CharField(
+        max_length=255,
+        blank=True,
+        default='',
+        help_text='Secret used to sign webhook payloads via HMAC-SHA256.',
+    )
+    events = models.CharField(
+        max_length=500,
+        default='*',
+        help_text='Comma-separated list of event categories, or * for all.',
+    )
+    active = models.BooleanField(default=True)
+    creator = models.ForeignKey(
+        User,
+        related_name='+',
+        on_delete=models.CASCADE,
+    )
+    created = models.DateTimeField(default=tz_utils.now)
+
+    def matches_event(self, category):
+        if self.events == '*':
+            return True
+        return category in self.events.split(',')
+
+    def __repr__(self):
+        return "<Webhook id='%d' url='%s'>" % (self.id, self.url)
+
+    class Meta:
+        ordering = ['id']
+        unique_together = [('project', 'url')]
+
+
 class EmailConfirmation(models.Model):
     validity = datetime.timedelta(days=settings.CONFIRMATION_VALIDITY_DAYS)
     type = models.CharField(
diff --git a/patchwork/signals.py b/patchwork/signals.py
index d7dd3463de0a..b4568bfab642 100644
--- a/patchwork/signals.py
+++ b/patchwork/signals.py
@@ -16,6 +16,7 @@ from patchwork.models import Patch
 from patchwork.models import PatchChangeNotification
 from patchwork.models import PatchComment
 from patchwork.models import Series
+from patchwork.webhooks import deliver_webhooks
 
 
 @receiver(pre_save, sender=Patch)
@@ -69,7 +70,7 @@ def create_cover_created_event(sender, instance, created, 
raw, **kwargs):
     if raw or not created:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(post_save, sender=Patch)
@@ -85,7 +86,7 @@ def create_patch_created_event(sender, instance, created, 
raw, **kwargs):
     if raw or not created:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(pre_save, sender=Patch)
@@ -109,7 +110,7 @@ def create_patch_state_changed_event(sender, instance, raw, 
**kwargs):
     if orig_patch.state == instance.state:
         return
 
-    create_event(instance, orig_patch.state, instance.state)
+    deliver_webhooks(create_event(instance, orig_patch.state, instance.state))
 
 
 @receiver(pre_save, sender=Patch)
@@ -133,7 +134,9 @@ def create_patch_delegated_event(sender, instance, raw, 
**kwargs):
     if orig_patch.delegate == instance.delegate:
         return
 
-    create_event(instance, orig_patch.delegate, instance.delegate)
+    deliver_webhooks(
+        create_event(instance, orig_patch.delegate, instance.delegate)
+    )
 
 
 @receiver(pre_save, sender=Patch)
@@ -155,7 +158,7 @@ def create_patch_relation_changed_event(sender, instance, 
raw, **kwargs):
     if orig_patch.related == instance.related:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(pre_save, sender=Patch)
@@ -188,7 +191,7 @@ def create_patch_completed_event(sender, instance, raw, 
**kwargs):
     if predecessors.count() != instance.number - 1:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
     # if this satisfies dependencies for successor patch, raise events for
     # those
@@ -199,7 +202,7 @@ def create_patch_completed_event(sender, instance, raw, 
**kwargs):
         if successor.number != count:
             break
 
-        create_event(successor)
+        deliver_webhooks(create_event(successor))
         count += 1
 
 
@@ -220,7 +223,7 @@ def create_check_created_event(sender, instance, created, 
raw, **kwargs):
     if raw or not created:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(post_save, sender=Series)
@@ -236,7 +239,7 @@ def create_series_created_event(sender, instance, created, 
raw, **kwargs):
     if raw or not created:
         return
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(pre_save, sender=Patch)
@@ -270,7 +273,7 @@ def create_series_completed_event(sender, instance, raw, 
**kwargs):
     # we can't use "series.received_all" here since we haven't actually saved
     # the instance yet so we duplicate that logic here but with an offset
     if (instance.series.received_total + 1) >= instance.series.total:
-        create_event(instance.series)
+        deliver_webhooks(create_event(instance.series))
 
 
 @receiver(post_save, sender=CoverComment)
@@ -283,7 +286,7 @@ def create_cover_comment_created_event(sender, instance, 
raw, **kwargs):
             cover_comment=comment,
         )
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
 
 
 @receiver(post_save, sender=PatchComment)
@@ -296,4 +299,4 @@ def create_patch_comment_created_event(sender, instance, 
raw, **kwargs):
             patch_comment=comment,
         )
 
-    create_event(instance)
+    deliver_webhooks(create_event(instance))
diff --git a/patchwork/tests/unit/api/test_webhook.py 
b/patchwork/tests/unit/api/test_webhook.py
new file mode 100644
index 000000000000..dda21b94ece4
--- /dev/null
+++ b/patchwork/tests/unit/api/test_webhook.py
@@ -0,0 +1,251 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <[email protected]>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import hashlib
+import hmac
+import json
+from http.server import BaseHTTPRequestHandler
+from http.server import HTTPServer
+import threading
+
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework import status
+
+from patchwork.tests.api import utils
+from patchwork.tests.utils import create_maintainer
+from patchwork.tests.utils import create_patch
+from patchwork.tests.utils import create_project
+from patchwork.tests.utils import create_series
+from patchwork.tests.utils import create_user
+from patchwork.tests.utils import create_webhook
+
+NO_VALIDATE = {
+    'validate_request': False,
+    'validate_response': False,
+}
+
+
+@override_settings(ENABLE_REST_API=True)
+class TestWebhookAPI(utils.APITestCase):
+    @staticmethod
+    def api_url(project_id, item=None, version='1.4'):
+        kwargs = {'project_id': project_id}
+        if version:
+            kwargs['version'] = version
+        if item is None:
+            return reverse('api-webhook-list', kwargs=kwargs)
+        kwargs['pk'] = item
+        return reverse('api-webhook-detail', kwargs=kwargs)
+
+    def test_list_anonymous(self):
+        project = create_project()
+        resp = self.client.get(self.api_url(project.id), **NO_VALIDATE)
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
+
+    def test_list_non_maintainer(self):
+        project = create_project()
+        user = create_user()
+        self.client.authenticate(user)
+        resp = self.client.get(self.api_url(project.id), **NO_VALIDATE)
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
+
+    def test_list_empty(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        self.client.authenticate(user)
+        resp = self.client.get(self.api_url(project.id), **NO_VALIDATE)
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(0, len(resp.data))
+
+    def test_create(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        self.client.authenticate(user)
+        resp = self.client.post(
+            self.api_url(project.id),
+            {
+                'url': 'http://example.com/hook',
+                'secret': 's3cret',
+                'events': '*',
+            },
+            **NO_VALIDATE,
+        )
+        self.assertEqual(status.HTTP_201_CREATED, resp.status_code)
+        self.assertEqual('http://example.com/hook', resp.data['url'])
+        self.assertNotIn('secret', resp.data)
+        self.assertTrue(resp.data['active'])
+
+    def test_create_specific_events(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        self.client.authenticate(user)
+        resp = self.client.post(
+            self.api_url(project.id),
+            {
+                'url': 'http://example.com/hook',
+                'events': 'patch-created,series-completed',
+            },
+            **NO_VALIDATE,
+        )
+        self.assertEqual(status.HTTP_201_CREATED, resp.status_code)
+        self.assertEqual('patch-created,series-completed', resp.data['events'])
+
+    def test_create_invalid_events(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        self.client.authenticate(user)
+        resp = self.client.post(
+            self.api_url(project.id),
+            {
+                'url': 'http://example.com/hook',
+                'events': 'invalid-event',
+            },
+            **NO_VALIDATE,
+        )
+        self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code)
+
+    def test_detail(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        webhook = create_webhook(project=project, creator=user)
+        self.client.authenticate(user)
+        resp = self.client.get(
+            self.api_url(project.id, webhook.id), **NO_VALIDATE
+        )
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual(webhook.url, resp.data['url'])
+        self.assertNotIn('secret', resp.data)
+
+    def test_update(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        webhook = create_webhook(project=project, creator=user)
+        self.client.authenticate(user)
+        resp = self.client.patch(
+            self.api_url(project.id, webhook.id),
+            {'active': False},
+            **NO_VALIDATE,
+        )
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertFalse(resp.data['active'])
+
+    def test_delete(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        webhook = create_webhook(project=project, creator=user)
+        self.client.authenticate(user)
+        resp = self.client.delete(
+            self.api_url(project.id, webhook.id), **NO_VALIDATE
+        )
+        self.assertEqual(status.HTTP_204_NO_CONTENT, resp.status_code)
+
+    def test_secret_write_only(self):
+        project = create_project()
+        user = create_maintainer(project=project)
+        webhook = create_webhook(
+            project=project, creator=user, secret='mysecret'
+        )
+        self.client.authenticate(user)
+        resp = self.client.get(
+            self.api_url(project.id, webhook.id), **NO_VALIDATE
+        )
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertNotIn('secret', resp.data)
+
+
+@override_settings(ENABLE_REST_API=True)
+class TestWebhookDelivery(utils.APITestCase):
+    def test_delivery_on_patch_created(self):
+        received = []
+
+        class Handler(BaseHTTPRequestHandler):
+            def do_POST(self):
+                length = int(self.headers['Content-Length'])
+                body = self.rfile.read(length)
+                received.append(
+                    {
+                        'body': body,
+                        'headers': dict(self.headers),
+                    }
+                )
+                self.send_response(200)
+                self.end_headers()
+
+            def log_message(self, format, *args):
+                pass
+
+        server = HTTPServer(('127.0.0.1', 0), Handler)
+        port = server.server_address[1]
+        thread = threading.Thread(target=server.serve_forever)
+        thread.daemon = True
+        thread.start()
+
+        try:
+            project = create_project()
+            secret = 'test-webhook-secret'
+            create_webhook(
+                project=project,
+                url='http://127.0.0.1:%d/' % port,
+                secret=secret,
+                events='*',
+            )
+            series = create_series(project=project)
+            create_patch(project=project, series=series)
+        finally:
+            server.shutdown()
+            thread.join(timeout=5)
+
+        self.assertTrue(len(received) > 0)
+
+        req = received[0]
+        expected_sig = hmac.new(
+            secret.encode(), req['body'], hashlib.sha256
+        ).hexdigest()
+        self.assertEqual(
+            req['headers']['X-Patchwork-Signature'],
+            'sha256=' + expected_sig,
+        )
+
+        payload = json.loads(req['body'])
+        self.assertIn('category', payload)
+        self.assertIn('payload', payload)
+
+    def test_delivery_filtered_by_events(self):
+        received = []
+
+        class Handler(BaseHTTPRequestHandler):
+            def do_POST(self):
+                length = int(self.headers['Content-Length'])
+                body = self.rfile.read(length)
+                received.append(json.loads(body))
+                self.send_response(200)
+                self.end_headers()
+
+            def log_message(self, format, *args):
+                pass
+
+        server = HTTPServer(('127.0.0.1', 0), Handler)
+        port = server.server_address[1]
+        thread = threading.Thread(target=server.serve_forever)
+        thread.daemon = True
+        thread.start()
+
+        try:
+            project = create_project()
+            create_webhook(
+                project=project,
+                url='http://127.0.0.1:%d/' % port,
+                events='series-created',
+            )
+            series = create_series(project=project)
+            create_patch(project=project, series=series)
+        finally:
+            server.shutdown()
+            thread.join(timeout=5)
+
+        categories = [r['category'] for r in received]
+        self.assertIn('series-created', categories)
+        self.assertNotIn('patch-created', categories)
diff --git a/patchwork/tests/utils.py b/patchwork/tests/utils.py
index 4f40489126f0..4d6c23658b6b 100644
--- a/patchwork/tests/utils.py
+++ b/patchwork/tests/utils.py
@@ -23,6 +23,7 @@ from patchwork.models import Project
 from patchwork.models import Series
 from patchwork.models import SeriesReference
 from patchwork.models import State
+from patchwork.models import Webhook
 from patchwork.tests import TEST_PATCH_DIR
 
 SAMPLE_DIFF = """--- /dev/null\t2011-01-01 00:00:00.000000000 +0800
@@ -314,6 +315,20 @@ def create_series_reference(**kwargs):
     return SeriesReference.objects.create(**values)
 
 
+def create_webhook(**kwargs):
+    """Create 'Webhook' object."""
+    values = {
+        'project': create_project() if 'project' not in kwargs else None,
+        'url': 'http://example.com/webhook',
+        'secret': 'test-secret',
+        'events': '*',
+        'creator': create_user() if 'creator' not in kwargs else None,
+    }
+    values.update(**kwargs)
+
+    return Webhook.objects.create(**values)
+
+
 def create_relation(**kwargs):
     """Create 'PatchRelation' object."""
     return PatchRelation.objects.create(**kwargs)
diff --git a/patchwork/urls.py b/patchwork/urls.py
index 11cd8e7c152a..5fadea10d9cb 100644
--- a/patchwork/urls.py
+++ b/patchwork/urls.py
@@ -229,6 +229,7 @@ if settings.ENABLE_REST_API:
     from patchwork.api import project as api_project_views  # noqa
     from patchwork.api import series as api_series_views  # noqa
     from patchwork.api import user as api_user_views  # noqa
+    from patchwork.api import webhook as api_webhook_views  # noqa
 
     api_patterns = [
         path('', api_index_views.IndexView.as_view(), name='api-index'),
@@ -345,6 +346,19 @@ if settings.ENABLE_REST_API:
         ),
     ]
 
+    api_1_4_patterns = [
+        path(
+            'projects/<project_id>/webhooks/',
+            api_webhook_views.WebhookList.as_view(),
+            name='api-webhook-list',
+        ),
+        path(
+            'projects/<project_id>/webhooks/<int:pk>/',
+            api_webhook_views.WebhookDetail.as_view(),
+            name='api-webhook-detail',
+        ),
+    ]
+
     urlpatterns += [
         re_path(
             r'^api/(?:(?P<version>(1.0|1.1|1.2|1.3|1.4))/)?',
@@ -355,8 +369,10 @@ if settings.ENABLE_REST_API:
             include(api_1_1_patterns),
         ),
         re_path(
-            r'^api/(?:(?P<version>(1.3|1.4))/)?', include(api_1_3_patterns)
+            r'^api/(?:(?P<version>(1.3|1.4))/)?',
+            include(api_1_3_patterns),
         ),
+        re_path(r'^api/(?:(?P<version>(1.4))/)?', include(api_1_4_patterns)),
         # token change
         path(
             'user/generate-token/',
diff --git a/patchwork/webhooks.py b/patchwork/webhooks.py
new file mode 100644
index 000000000000..bf429746a542
--- /dev/null
+++ b/patchwork/webhooks.py
@@ -0,0 +1,88 @@
+# Patchwork - automated patch tracking system
+# Copyright (C) 2026 Robin Jarry <[email protected]>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import hashlib
+import hmac
+import json
+import logging
+import urllib.request
+
+from django.db import transaction
+
+from patchwork.models import Webhook
+
+logger = logging.getLogger(__name__)
+
+REQUEST_TIMEOUT = 10
+
+
+def _serialize_event(event):
+    from patchwork.api.event import EventSerializer
+
+    from django.conf import settings
+    from django.contrib.sites.models import Site
+    from rest_framework.request import Request
+    from rest_framework.test import APIRequestFactory
+
+    site = Site.objects.get_current()
+    factory = APIRequestFactory(SERVER_NAME=site.domain)
+    request = Request(factory.get('/', secure=settings.FORCE_HTTPS_LINKS))
+    request.version = '1.4'
+    serializer = EventSerializer(event, context={'request': request})
+    return serializer.data
+
+
+def deliver_webhooks(event):
+    transaction.on_commit(lambda: _post_webhooks(event))
+
+
+def _post_webhooks(event):
+    try:
+        webhooks = Webhook.objects.filter(project=event.project, active=True)
+        if not webhooks:
+            return
+
+        payload = None
+
+        for webhook in webhooks:
+            if not webhook.matches_event(event.category):
+                continue
+
+            if payload is None:
+                payload = json.dumps(_serialize_event(event)).encode()
+
+            headers = {
+                'Content-Type': 'application/json',
+                'X-Patchwork-Event': event.category,
+                'X-Patchwork-Delivery': str(event.id),
+            }
+
+            if webhook.secret:
+                sig = hmac.new(
+                    webhook.secret.encode(),
+                    payload,
+                    hashlib.sha256,
+                ).hexdigest()
+                headers['X-Patchwork-Signature'] = 'sha256=' + sig
+
+            req = urllib.request.Request(
+                webhook.url,
+                data=payload,
+                headers=headers,
+                method='POST',
+            )
+            try:
+                with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT):
+                    pass
+            except Exception:
+                logger.warning(
+                    'webhook delivery failed for %r',
+                    webhook,
+                    exc_info=True,
+                )
+    except Exception:
+        logger.warning(
+            'webhook delivery failed for event %r', event, exc_info=True
+        )
diff --git a/releasenotes/notes/webhook-support-a1b2c3d4e5f6g7h8.yaml 
b/releasenotes/notes/webhook-support-a1b2c3d4e5f6g7h8.yaml
new file mode 100644
index 000000000000..cef25997aee0
--- /dev/null
+++ b/releasenotes/notes/webhook-support-a1b2c3d4e5f6g7h8.yaml
@@ -0,0 +1,13 @@
+---
+features:
+  - |
+    Webhook support for event delivery. Projects can now have webhooks
+    configured via the Django admin interface. When events occur (patch
+    created, series completed, state changed, etc.), patchwork will POST
+    a JSON payload to each matching webhook URL. Payloads can optionally
+    be signed with HMAC-SHA256 using a shared secret.
+api:
+  - |
+    Add webhook management endpoints under
+    ``/api/projects/<id>/webhooks/``. Webhooks can be filtered by event
+    category and secured with a shared secret for payload signing.
-- 
2.54.0

_______________________________________________
Patchwork mailing list
[email protected]
https://lists.ozlabs.org/listinfo/patchwork

Reply via email to