diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru
index a370bfb6..7d219d7b 100644
--- a/.bruno/Chromium/Convert/HTML to PDF.bru
+++ b/.bruno/Chromium/Convert/HTML to PDF.bru
@@ -66,6 +66,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru
index 7ecd91b8..ac850fc9 100644
--- a/.bruno/Chromium/Convert/Markdown to PDF.bru
+++ b/.bruno/Chromium/Convert/Markdown to PDF.bru
@@ -67,6 +67,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru
index d1ce9637..a40f75de 100644
--- a/.bruno/Chromium/Convert/URL to PDF.bru
+++ b/.bruno/Chromium/Convert/URL to PDF.bru
@@ -66,6 +66,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/Chromium/Screenshot/HTML Screenshot.bru b/.bruno/Chromium/Screenshot/HTML Screenshot.bru
index 027856c1..b44b4bc4 100644
--- a/.bruno/Chromium/Screenshot/HTML Screenshot.bru
+++ b/.bruno/Chromium/Screenshot/HTML Screenshot.bru
@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/Chromium/Screenshot/Markdown Screenshot.bru b/.bruno/Chromium/Screenshot/Markdown Screenshot.bru
index a5ed077e..c5d5f1d4 100644
--- a/.bruno/Chromium/Screenshot/Markdown Screenshot.bru
+++ b/.bruno/Chromium/Screenshot/Markdown Screenshot.bru
@@ -41,6 +41,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/Chromium/Screenshot/URL Screenshot.bru b/.bruno/Chromium/Screenshot/URL Screenshot.bru
index fbd4c81b..76f65c5c 100644
--- a/.bruno/Chromium/Screenshot/URL Screenshot.bru
+++ b/.bruno/Chromium/Screenshot/URL Screenshot.bru
@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru
index 2ca99670..90816587 100644
--- a/.bruno/LibreOffice/Convert to PDF.bru
+++ b/.bruno/LibreOffice/Convert to PDF.bru
@@ -83,6 +83,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru b/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru
index 22328ecd..b2e712c8 100644
--- a/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru
+++ b/.bruno/PDF Engines/Bookmarks/Read Bookmarks.bru
@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru b/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru
index 0ec1b508..54fb0809 100644
--- a/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru
+++ b/.bruno/PDF Engines/Bookmarks/Write Bookmarks.bru
@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-bookmarks
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Convert/Convert PDF.bru b/.bruno/PDF Engines/Convert/Convert PDF.bru
index 5de2769d..aa7b4981 100644
--- a/.bruno/PDF Engines/Convert/Convert PDF.bru
+++ b/.bruno/PDF Engines/Convert/Convert PDF.bru
@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: converted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Embed/Embed Files.bru b/.bruno/PDF Engines/Embed/Embed Files.bru
index 3af440bb..30bf3497 100644
--- a/.bruno/PDF Engines/Embed/Embed Files.bru
+++ b/.bruno/PDF Engines/Embed/Embed Files.bru
@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: with-embeds
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
index c60c5c53..7ede2a7b 100644
--- a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
+++ b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru
@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: encrypted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Flatten/Flatten PDF.bru b/.bruno/PDF Engines/Flatten/Flatten PDF.bru
index 114792ca..9260f667 100644
--- a/.bruno/PDF Engines/Flatten/Flatten PDF.bru
+++ b/.bruno/PDF Engines/Flatten/Flatten PDF.bru
@@ -18,6 +18,7 @@ headers {
~Gotenberg-Output-Filename: flattened
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru
index aeadad1a..62114aba 100644
--- a/.bruno/PDF Engines/Merge/Merge PDFs.bru
+++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru
@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: merged
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Metadata/Read Metadata.bru b/.bruno/PDF Engines/Metadata/Read Metadata.bru
index 3d8f9a53..6a9f027c 100644
--- a/.bruno/PDF Engines/Metadata/Read Metadata.bru
+++ b/.bruno/PDF Engines/Metadata/Read Metadata.bru
@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Metadata/Write Metadata.bru b/.bruno/PDF Engines/Metadata/Write Metadata.bru
index 6beff763..16fad817 100644
--- a/.bruno/PDF Engines/Metadata/Write Metadata.bru
+++ b/.bruno/PDF Engines/Metadata/Write Metadata.bru
@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-metadata
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Rotate/Rotate PDF.bru b/.bruno/PDF Engines/Rotate/Rotate PDF.bru
index 3ec7c59e..bc32bd4e 100644
--- a/.bruno/PDF Engines/Rotate/Rotate PDF.bru
+++ b/.bruno/PDF Engines/Rotate/Rotate PDF.bru
@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: rotated
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru
index 130696f3..51cf3c97 100644
--- a/.bruno/PDF Engines/Split/Split PDF.bru
+++ b/.bruno/PDF Engines/Split/Split PDF.bru
@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: split
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Stamp/Stamp PDF.bru b/.bruno/PDF Engines/Stamp/Stamp PDF.bru
index a1710ab2..5badb844 100644
--- a/.bruno/PDF Engines/Stamp/Stamp PDF.bru
+++ b/.bruno/PDF Engines/Stamp/Stamp PDF.bru
@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: stamped
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/.bruno/PDF Engines/Watermark/Watermark PDF.bru b/.bruno/PDF Engines/Watermark/Watermark PDF.bru
index 4ff99dd2..99b8e00f 100644
--- a/.bruno/PDF Engines/Watermark/Watermark PDF.bru
+++ b/.bruno/PDF Engines/Watermark/Watermark PDF.bru
@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: watermarked
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
+ ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go
index b3d6e115..7fefab01 100644
--- a/pkg/modules/webhook/client.go
+++ b/pkg/modules/webhook/client.go
@@ -2,6 +2,7 @@ package webhook
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
@@ -26,6 +27,7 @@ type client struct {
method string
errorUrl string
errorMethod string
+ eventsUrl string
extraHttpHeaders map[string]string
startTime time.Time
@@ -144,3 +146,66 @@ func (c client) send(ctx context.Context, body io.Reader, headers map[string]str
return nil
}
+
+// sendEvent sends a structured JSON event to the events URL. It is
+// fire-and-forget: failures are logged but do not propagate.
+func (c client) sendEvent(ctx context.Context, correlationIdHeader, correlationId string, event map[string]any) {
+ if c.eventsUrl == "" {
+ return
+ }
+
+ b, err := json.Marshal(event)
+ if err != nil {
+ c.logger.ErrorContext(ctx, fmt.Sprintf("marshal webhook event: %s", err))
+ return
+ }
+
+ tracer := gotenberg.Tracer()
+ ctx, span := tracer.Start(ctx, "POST Webhook Event",
+ trace.WithSpanKind(trace.SpanKindClient),
+ trace.WithAttributes(semconv.ServerAddress(c.eventsUrl)),
+ )
+ defer span.End()
+
+ req, err := retryablehttp.NewRequest(http.MethodPost, c.eventsUrl, b)
+ if err != nil {
+ span.RecordError(err)
+ span.SetStatus(codes.Error, err.Error())
+ c.logger.ErrorContext(ctx, fmt.Sprintf("create webhook event request: %s", err))
+ return
+ }
+
+ otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
+
+ req.Header.Set("User-Agent", "Gotenberg")
+ for key, value := range c.extraHttpHeaders {
+ req.Header.Set(key, value)
+ }
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ req.Header.Set(correlationIdHeader, correlationId)
+
+ resp, err := c.client.Do(req)
+ if err != nil {
+ span.RecordError(err)
+ span.SetStatus(codes.Error, err.Error())
+ c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': %s", c.eventsUrl, err))
+ return
+ }
+
+ defer func() {
+ err := resp.Body.Close()
+ if err != nil {
+ c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", c.eventsUrl, err))
+ }
+ }()
+
+ if resp.StatusCode >= http.StatusBadRequest {
+ span.RecordError(fmt.Errorf("webhook event: got status '%s'", resp.Status))
+ span.SetStatus(codes.Error, resp.Status)
+ c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': got status '%s'", c.eventsUrl, resp.Status))
+ return
+ }
+
+ span.SetStatus(codes.Ok, "")
+ c.logger.InfoContext(ctx, fmt.Sprintf("webhook event sent to '%s'", c.eventsUrl))
+}
diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go
index a6e2df18..482d10ed 100644
--- a/pkg/modules/webhook/middleware.go
+++ b/pkg/modules/webhook/middleware.go
@@ -85,7 +85,14 @@ func webhookMiddleware(w *Webhook) api.Middleware {
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
+ return
}
+
+ params.client.sendEvent(params.ctx, params.correlationIdHeader, params.correlationId, map[string]any{
+ "event": "webhook.success",
+ "correlationId": params.correlationId,
+ "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
+ })
}
return func(c echo.Context) error {
@@ -176,6 +183,15 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
+ // What about the events URL?
+ webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url")
+ if webhookEventsUrl != "" {
+ err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline)
+ if err != nil {
+ return fmt.Errorf("filter webhook events URL: %w", err)
+ }
+ }
+
// Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
@@ -187,6 +203,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
+ eventsUrl: webhookEventsUrl,
extraHttpHeaders: extraHttpHeaders,
startTime: startTime,
@@ -233,6 +250,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
+
+ client.sendEvent(ctx, correlationIdHeader, correlationId, map[string]any{
+ "event": "webhook.error",
+ "correlationId": correlationId,
+ "timestamp": time.Now().UTC().Format(time.RFC3339Nano),
+ "error": map[string]any{
+ "status": status,
+ "message": message,
+ },
+ })
}
if w.enableSyncMode {
diff --git a/test/integration/AGENTS.md b/test/integration/AGENTS.md
index c002dddb..bea4431c 100644
--- a/test/integration/AGENTS.md
+++ b/test/integration/AGENTS.md
@@ -63,6 +63,7 @@ make test-integration PLATFORM=linux/arm64 # Force a specific platform
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
+- `the webhook event should match JSON:` (docstring — use `"ignore"` for dynamic values; polls for up to 5s)
- `there should be PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "" PDF should have page(s)`
diff --git a/test/integration/features/webhook.feature b/test/integration/features/webhook.feature
index 072800eb..9229ce19 100644
--- a/test/integration/features/webhook.feature
+++ b/test/integration/features/webhook.feature
@@ -44,3 +44,45 @@ Feature: Webhook
Then the response status code should be 204
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
+
+ Scenario: Webhook Events URL (Success)
+ Given I have a default Gotenberg container
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ | Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
+ Then the response status code should be 204
+ When I wait for the asynchronous request to the webhook
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the webhook event should match JSON:
+ """
+ {
+ "event": "webhook.success",
+ "correlationId": "ignore",
+ "timestamp": "ignore"
+ }
+ """
+
+ Scenario: Webhook Events URL (Synchronous)
+ Given I have a Gotenberg container with the following environment variable(s):
+ | WEBHOOK_ENABLE_SYNC_MODE | true |
+ Given I have a webhook server
+ When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
+ | files | testdata/page_1.pdf | file |
+ | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
+ | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
+ | Gotenberg-Webhook-Events-Url | http://host.docker.internal:%d/webhook/events | header |
+ Then the response status code should be 204
+ Then the webhook request header "Content-Type" should be "application/pdf"
+ Then there should be 1 PDF(s) in the webhook request
+ Then the webhook event should match JSON:
+ """
+ {
+ "event": "webhook.success",
+ "correlationId": "ignore",
+ "timestamp": "ignore"
+ }
+ """
diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go
index 7ca8b31c..390aeebb 100644
--- a/test/integration/scenario/scenario.go
+++ b/test/integration/scenario/scenario.go
@@ -181,7 +181,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
}
files[name] = append(files[name], value)
case "header":
- if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" {
+ if name == "Gotenberg-Webhook-Url" || name == "Gotenberg-Webhook-Error-Url" || name == "Gotenberg-Webhook-Events-Url" {
headers[name] = fmt.Sprintf(value, s.hostPort)
continue
}
@@ -653,6 +653,48 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
return nil
}
+func (s *scenario) theWebhookEventShouldMatchJSON(ctx context.Context, expectedDoc *godog.DocString) error {
+ if s.server == nil {
+ return errors.New("server not initialized")
+ }
+
+ // Poll briefly — the event fires right after the main webhook.
+ var body []byte
+ deadline := time.After(5 * time.Second)
+ for {
+ body = s.server.getEventBody()
+ if body != nil {
+ break
+ }
+ select {
+ case <-deadline:
+ return errors.New("timed out waiting for webhook event")
+ default:
+ time.Sleep(100 * time.Millisecond)
+ }
+ }
+
+ var expected, actual any
+
+ content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
+ err := json.Unmarshal([]byte(content), &expected)
+ if err != nil {
+ return fmt.Errorf("unmarshal expected JSON: %w", err)
+ }
+
+ err = json.Unmarshal(body, &actual)
+ if err != nil {
+ return fmt.Errorf("unmarshal actual JSON: %w", err)
+ }
+
+ err = compareJson(expected, actual)
+ if err != nil {
+ return fmt.Errorf("expected matching webhook event JSON: %w", err)
+ }
+
+ return nil
+}
+
func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
dirPath := s.teststoreDir
@@ -1158,6 +1200,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
ctx.Then(`^the (response|webhook request) body should contain string:$`, s.theBodyShouldContainString)
ctx.Then(`^the (response|webhook request) body should match JSON:$`, s.theBodyShouldMatchJSON)
+ ctx.Then(`^the webhook event should match JSON:$`, s.theWebhookEventShouldMatchJSON)
ctx.Then(`^there should be (\d+) PDF\(s\) in the (response|webhook request)$`, s.thereShouldBePdfs)
ctx.Then(`^there should be the following file\(s\) in the (response|webhook request):$`, s.thereShouldBeTheFollowingFiles)
ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf)
diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go
index 41b67298..ad9144f4 100644
--- a/test/integration/scenario/server.go
+++ b/test/integration/scenario/server.go
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"github.com/cucumber/godog"
"github.com/google/uuid"
@@ -19,10 +20,12 @@ import (
)
type server struct {
- srv *echo.Echo
- req *http.Request
- bodyCopy []byte
- errChan chan error
+ srv *echo.Echo
+ req *http.Request
+ bodyCopy []byte
+ errChan chan error
+ eventBody []byte
+ eventMu sync.Mutex
}
func newServer(ctx context.Context, workdir string) (*server, error) {
@@ -144,6 +147,18 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
srv.POST("/webhook/error", webhookErrorHandler)
srv.PATCH("/webhook/error", webhookErrorHandler)
srv.PUT("/webhook/error", webhookErrorHandler)
+
+ webhookEventsHandler := func(c echo.Context) error {
+ body, err := io.ReadAll(c.Request().Body)
+ if err != nil {
+ return c.String(http.StatusInternalServerError, err.Error())
+ }
+ s.eventMu.Lock()
+ s.eventBody = body
+ s.eventMu.Unlock()
+ return c.String(http.StatusOK, http.StatusText(http.StatusOK))
+ }
+ srv.POST("/webhook/events", webhookEventsHandler)
srv.GET("/static/:path", func(c echo.Context) error {
s.req = c.Request()
path := c.Param("path")
@@ -170,6 +185,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
return s, nil
}
+func (s *server) getEventBody() []byte {
+ s.eventMu.Lock()
+ defer s.eventMu.Unlock()
+ return s.eventBody
+}
+
func (s *server) start(ctx context.Context) (int, error) {
// #nosec
ln, err := net.Listen("tcp", "0.0.0.0:0")