How to Receive PDF Generation Webhooks in Go
Build a Go webhook endpoint that verifies signatures, deduplicates retries, and queues PDF work before acknowledging delivery.

To receive PDF generation webhooks in Go, expose an HTTPS POST endpoint, limit and read the request body once, verify the provider’s signature against the exact raw bytes, validate the event, record an idempotency key, enqueue the work, and return a successful 2xx response quickly. Do not download or process the PDF in the request handler: webhook deliveries can be retried, and slow responses can create duplicate work.
This guide builds that flow with Go’s standard library. Signature headers, event schemas, retry policies, and download URL behavior differ by provider, so the verification and event-decoding points below must match the service sending your webhook. OpenAI’s webhook guide describes quick 2xx acknowledgements and retries for up to 72 hours; its Go SDK example uses a 1 MiB request-body limit. OpenAI webhook guide · OpenAI Go SDK.
1. Choose the request flow
Treat a webhook as a notification that work is ready, not as a request to finish all the work synchronously. Your handler should do only enough to establish that the event is authentic, valid, and durably accepted.

- Accept
POST /webhooks/pdfonly over HTTPS. - Reject oversized bodies and read the raw bytes once.
- Verify the signature using the provider’s documented scheme, secret, header names, and timestamp rules.
- Decode the verified event and validate its type and identifiers.
- Atomically claim the provider event ID in a durable store.
- Enqueue processing, then acknowledge with 2xx.
- Download, store, and process the PDF in a worker that can retry safely.
The critical ordering is raw-body verification before JSON unmarshalling. JSON can be reserialized with different whitespace or key ordering; a signature over the original request bytes would no longer match.
2. Create a runnable Go receiver
The following single-file example uses only the Go standard library. It demonstrates HMAC-SHA256 over the raw body, a timestamp header, a constant-time signature comparison, a bounded body, basic event validation, and in-memory duplicate suppression. The HMAC format is illustrative: replace it with your provider’s exact signing format or official SDK. The in-memory map is suitable only for a local demonstration; production idempotency must use durable storage with a unique constraint.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Document string `json:"document_id"`
CreatedAt int64 `json:"created_at"`
}
type Store struct {
mu sync.Mutex
seen map[string]struct{}
}
func (s *Store) InsertIfNew(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.seen[id]; ok { return false }
s.seen[id] = struct{}{}
return true
}
// Example scheme only: X-Webhook-Timestamp plus
// X-Webhook-Signature: hex(HMAC-SHA256(secret, timestamp + "." + rawBody)).
func verify(raw []byte, timestamp, signature, secret string) error {
if timestamp == "" || signature == "" || secret == "" {
return errors.New("missing signature material")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(raw)
want := mac.Sum(nil)
got, err := hex.DecodeString(strings.TrimPrefix(signature, "sha256="))
if err != nil || !hmac.Equal(got, want) {
return errors.New("signature mismatch")
}
return nil
}
func main() {
secret := os.Getenv("PDF_WEBHOOK_SECRET")
if secret == "" { log.Fatal("set PDF_WEBHOOK_SECRET") }
store := &Store{seen: make(map[string]struct{})}
mux := http.NewServeMux()
mux.HandleFunc("POST /webhooks/pdf", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB example limit
defer r.Body.Close()
raw, err := io.ReadAll(r.Body)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge); return }
http.Error(w, "cannot read body", http.StatusBadRequest); return
}
if err := verify(raw, r.Header.Get("X-Webhook-Timestamp"), r.Header.Get("X-Webhook-Signature"), secret); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized); return
}
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest); return
}
if event.ID == "" || event.Document == "" || event.Type == "" {
http.Error(w, "missing event fields", http.StatusBadRequest); return
}
if event.Type != "pdf.ready" && event.Type != "pdf.failed" {
w.WriteHeader(http.StatusNoContent); return // acknowledge ignored event types
}
if !store.InsertIfNew(event.ID) {
w.WriteHeader(http.StatusOK); return
}
// Production: durably enqueue event before acknowledging it.
log.Printf("accepted pdf event id=%q type=%q document=%q", event.ID, event.Type, event.Document)
w.WriteHeader(http.StatusAccepted)
})
server := &http.Server{
Addr: ":8080", Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
fmt.Println("listening on :8080")
log.Fatal(server.ListenAndServe())
}
Save it as main.go, then run:
go run main.go
For a local smoke check, generate a signature using the same sample scheme and send it through a publicly reachable HTTPS tunnel. The provider must be able to reach your endpoint; OpenAI’s guide names ngrok and cloud development environments as options for local testing. Do not use the illustrative header names above unless your provider actually sends them.
3. Verify signatures the provider’s way
The sample computes HMAC-SHA256 over timestamp + "." + rawBody, then compares bytes with hmac.Equal. A production implementation must follow the provider documentation exactly. Providers can differ in algorithm, signed message composition, timestamp encoding, signature version prefix, multiple signatures per header, and key rotation behavior.
- Read the body into
[]byteonce and verify those bytes. - Use the provider SDK’s verifier when available; it reduces format mistakes.
- Use constant-time comparison for MAC values.
- If the scheme signs a timestamp, enforce the documented tolerance to reduce replay risk.
- Support secret rotation as documented, often by accepting current and retiring secrets for a bounded interval.
- Never log secrets, full authorization headers, or sensitive document URLs.
Return a 4xx response when authentication fails so an attacker cannot make the service queue untrusted work. Be deliberate about provider retry behavior: a permanent malformed or invalid request generally should not be retried indefinitely.
4. Make delivery idempotent
Webhook senders retry when they cannot confirm successful receipt. The same event can therefore arrive more than once, even if your original handler already started processing it. Use the provider event ID or webhook ID as a stable idempotency key, not the PDF URL or a timestamp generated by your server.

In production, put a unique constraint on the event ID and create the work item in the same database transaction or through a transactional outbox. A robust sequence is:
- Begin a transaction.
- Insert the event ID into a table with a unique index.
- If the insert conflicts, commit or roll back and return 2xx: the event was already accepted.
- Insert an outbox/job row associated with that event.
- Commit, then return 2xx.
This avoids the failure window where the endpoint records an event as seen but crashes before queueing it. If the queue has native deduplication, use it as an additional safeguard, not a replacement for durable event identity. Workers should also be idempotent: storing the same PDF twice should not create duplicate business actions.
5. Download and process PDFs in a worker
Do not download a potentially large PDF before acknowledging the webhook. The provider may impose a short response deadline; OpenAI says a slow or unsuccessful response can trigger retries, with delivery retried for up to 72 hours using exponential backoff. A handler that downloads first can time out after doing the work, causing a duplicate delivery.
After durable enqueueing, a worker should fetch the document, validate the response status and content type, impose a download-size limit, and store it using a stable document or event key. Treat download URLs as secrets if they grant access. If a URL expires, use the provider’s job/status endpoint or request a fresh URL according to its API.
PDF generation providers expose different async patterns. PDF Generator API documents POST /documents/generate/async and GET /documents/async/{jobId} for async generation and status lookup, and documents limits of 2 requests per second and 60 requests per minute. Its Go client documentation lists API version 4.0.28. PDF Generator API Go client. PDFMonkey distinguishes documents.generation.success, where download_url is available, from documents.generation.failure, which includes failure_cause; its webhook docs describe automatic retries and signature verification. PDFMonkey webhook documentation.
6. Configure the HTTP server and endpoint
The example sets read, write, header, and idle timeouts. Tune them to your proxy and provider behavior, but keep the handler’s work fast. Deploy behind a TLS-terminating load balancer or configure TLS directly, and ensure the public route preserves the request body and relevant signature headers unchanged.
| Setting | Purpose | Practical guidance |
|---|---|---|
| Body limit | Prevents unbounded memory use | Choose a limit suitable for event metadata; 1 MiB is the OpenAI Go example value, not a universal requirement. |
| ReadHeaderTimeout | Bounds slow header delivery | Set a finite value at the server and edge proxy. |
| ReadTimeout | Bounds body read time | Set with the expected small webhook payload in mind. |
| WriteTimeout | Prevents stuck responses | Allow enough time for database/queue acknowledgement, not PDF generation. |
| IdleTimeout | Limits idle keep-alive connections | Coordinate with the reverse proxy. |
| Concurrency | Controls simultaneous handler work | Protect database and queue capacity; return retryable failures if durable acceptance is unavailable. |
Also restrict methods, validate content type if the provider guarantees JSON, and consider trusted proxy configuration. Do not trust a client-supplied forwarding header for security decisions unless a trusted proxy overwrites it.
7. Observe, retry, and operate the pipeline
Log a request correlation ID, provider event ID, event type, verification result, duplicate status, and queue outcome. Avoid logging the raw body by default because webhook events can contain personal or confidential data. Track accepted, rejected, duplicate, enqueue-failed, worker-retried, and terminal-failure counts. Alert on queue age and a growing failure count; those indicate that a fast-ack endpoint may be accepting work faster than workers can finish it.
If the durable store or queue is unavailable, do not return success unless the event has been durably accepted elsewhere. A 5xx can prompt provider retry; return it quickly and let the sender retry according to its policy. On the other hand, return 2xx for an already processed duplicate to stop unnecessary redelivery.
8. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Every signature fails | Body was parsed/reserialized, wrong secret, wrong signing string, or wrong header format | Verify the exact raw bytes and follow the provider’s SDK/docs; check secret environment and signature version. |
| Provider reports timeout and sends duplicates | Handler downloads/processes the PDF or waits on a slow dependency | Persist and enqueue quickly; move retrieval and business work to a worker. |
| Duplicate business records appear | Only in-memory dedupe or no unique constraint; retries race | Use a durable unique event key and make worker side effects idempotent. |
| Events disappear after a 2xx | Handler acknowledged before enqueue was durable | Use a transactional outbox or commit the event and job atomically before responding. |
| 413 response | Body exceeds configured maximum or proxy limit | Compare provider payload size with application and proxy limits; raise only to a documented, bounded value. |
| Webhook never reaches local server | Local address is not Internet reachable, tunnel URL changed, or TLS/proxy routing is wrong | Use a public tunnel or cloud dev endpoint and update the provider’s configured URL. |
| PDF download returns 403 or 404 | URL expired, requires authorization, or event represents a failure | Check event type and provider download rules; fetch a fresh URL via its documented job/status API. |
| 429 responses from provider API | Worker exceeds provider rate limits | Use bounded concurrency, backoff, and rate limiting; PDF Generator API documents 2 requests/second and 60/minute. |
9. Performance, reliability, and cost
The webhook handler should do work proportional to the small event payload: bounded read, signature verification, validation, durable insert/enqueue, and response. Keep PDF transfer and parsing out of this path. This keeps response time predictable and reduces duplicated transfers when the sender retries.
Bound worker concurrency and queue growth. Add retries with backoff for transient download and storage errors, and a terminal state or dead-letter path for repeated failures. Do not retry permanent schema or authorization errors forever. Set download limits and storage retention based on your document sensitivity and business needs.
Cost depends on the provider’s generation, storage, transfer, and queue pricing; those rates are not specified here and should be checked against the chosen provider’s current plan. Keep an eye on duplicate generation or download attempts, because poor idempotency can multiply usage. Rate limits are provider-specific and can change, so verify them in current docs before selecting worker concurrency.
10. Where ScreenshotNeo fits
If a PDF workflow also needs a visual record of the source webpage, ScreenshotNeo is a website screenshot API and MCP server. A single GET request returns PNG, JPEG, WebP, or PDF. It can capture full pages, a CSS-selected element, or a chosen device/viewport, and its API accepts the parameter names used by other screenshot APIs to ease migration.
Or skip the browser setup: make one API call to capture the source URL. See the ScreenshotNeo API docs for options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups, and chat widgets are removed before capture; each cleanup step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed, and response headers say the page verdict and billing status. An MCP server gives AI agents tools to take screenshots, inspect page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Sign up free for 1,000 screenshots a month, no card required.
11. Frequently asked questions
Should I return 200 or 202?
Either is a successful 2xx acknowledgement if the provider accepts it. Choose a documented success code and return it only after durable acceptance. The example uses 202 for a newly queued event and 200 for a duplicate.
Can I use the PDF URL as the idempotency key?
Prefer a stable provider event ID. URLs can expire, rotate, or differ across deliveries for the same event.
What if my provider has no signature?
Use the provider’s documented authentication method, such as a secret token or signed URL, and add network or application controls appropriate to that scheme. Do not assume a guessed header makes an endpoint authentic.
How do I test retries?
Use the provider’s delivery/replay tools if available, or send the same valid event ID twice to a publicly reachable test endpoint. Confirm the second request returns 2xx without repeating the business action.
Does every PDF webhook include a download URL?
No. Some events signal failure or provide a job identifier instead. Decode the provider’s documented event type and retrieve the document through its documented status/download flow.


