How to Add Webhooks to Your SaaS API in 10 Minutes

By ThunderHooks Team · · 4 min read
How to Add Webhooks to Your SaaS API in 10 Minutes

How to Add Webhooks to Your SaaS API

Your customers want to know when things happen in your product. An invoice was paid, a user signed up, a deployment finished. Polling your API every 30 seconds is wasteful. Webhooks solve this by pushing events to your customers the moment something happens.

Building webhook infrastructure from scratch means dealing with delivery retries, signature verification, endpoint management, failure alerting, and a queue system to handle it all asynchronously. That's weeks of work you could spend on your actual product.

Here's how to add production-grade outbound webhooks to your SaaS in about 10 minutes using ThunderHooks.

What You're Building

By the end of this guide, your API will be able to:

  • Define event types (e.g., invoice.paid, user.created)
  • Let customers register webhook endpoints through a portal
  • Deliver signed events with automatic retries
  • Provide customers with tools to verify payload authenticity

All webhook delivery follows the Standard Webhooks spec, so your customers get a consistent experience regardless of which libraries or languages they use.

Step 1: Create a ThunderHooks Application

Sign up at thunderhooks.com and create a new application. Each application maps to one of your products or services.

You'll get an API key and an application ID. Store these somewhere safe — you'll need them for API calls.

export TH_API_KEY="thk_your_api_key_here"
export TH_APP_ID="app_abc123"

Step 2: Define Your Event Types

Event types tell ThunderHooks (and your customers) what kinds of notifications your system sends. Define them through the API:

curl -X POST https://api.thunderhooks.com/v1/apps/$TH_APP_ID/event-types \
  -H "Authorization: Bearer $TH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice.paid",
    "description": "Fired when an invoice is successfully paid"
  }'
curl -X POST https://api.thunderhooks.com/v1/apps/$TH_APP_ID/event-types \
  -H "Authorization: Bearer $TH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "user.created",
    "description": "Fired when a new user account is created"
  }'

Create as many as you need. A good rule of thumb: one event type per meaningful state change in your domain. Don't over-segment (nobody needs user.first_name_updated and user.last_name_updated as separate events), but don't lump unrelated things together either.

Step 3: Register Customer Endpoints

Your customers need a way to tell you where to send webhooks. You can register endpoints on their behalf via the API:

curl -X POST https://api.thunderhooks.com/v1/apps/$TH_APP_ID/endpoints \
  -H "Authorization: Bearer $TH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://customer-app.example.com/webhooks",
    "event_types": ["invoice.paid", "user.created"],
    "description": "Acme Corp production endpoint"
  }'

The response includes the endpoint ID and a signing secret:

{
  "id": "ep_xyz789",
  "url": "https://customer-app.example.com/webhooks",
  "event_types": ["invoice.paid", "user.created"],
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

That signing_secret is what your customer uses to verify payloads. Share it with them securely.

Self-Service Endpoint Management

Rather than building your own endpoint management UI, you can embed the ThunderHooks customer portal directly in your dashboard. Generate a portal session for the logged-in customer:

curl -X POST https://api.thunderhooks.com/v1/apps/$TH_APP_ID/portal \
  -H "Authorization: Bearer $TH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "cust_12345"
  }'

This returns a short-lived URL you can embed in an iframe or redirect to. The portal lets customers add endpoints, choose which events to subscribe to, view delivery logs, and retry failed deliveries — all without you building any of that UI.

Step 4: Send Events

When something happens in your system, fire an event:

curl -X POST https://api.thunderhooks.com/v1/apps/$TH_APP_ID/events \
  -H "Authorization: Bearer $TH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "invoice.paid",
    "payload": {
      "invoice_id": "inv_001",
      "amount": 9900,
      "currency": "usd",
      "customer_email": "billing@acme.com",
      "paid_at": "2026-04-07T14:30:00Z"
    }
  }'

ThunderHooks takes it from there. It fans out the event to every endpoint subscribed to invoice.paid, signs each delivery, and retries on failure with exponential backoff.

In your application code, you'd call this right after the business logic completes. Here's what that looks like in a Go handler:

func (h *Handler) HandlePaymentSuccess(c echo.Context) error {
    invoice, err := h.billing.MarkPaid(c.Request().Context(), invoiceID)
    if err != nil {
        return err
    }

    // Fire the webhook event
    err = h.thunderhooks.SendEvent(c.Request().Context(), thunderhooks.Event{
        EventType: "invoice.paid",
        Payload: map[string]any{
            "invoice_id":     invoice.ID,
            "amount":         invoice.Amount,
            "currency":       invoice.Currency,
            "customer_email": invoice.CustomerEmail,
            "paid_at":        invoice.PaidAt.Format(time.RFC3339),
        },
    })
    if err != nil {
        // Log the error but don't fail the request — the payment succeeded
        h.logger.Error("failed to send webhook event", "error", err)
    }

    return c.JSON(http.StatusOK, invoice)
}

One thing to note: don't let webhook delivery failures break your main flow. The payment succeeded — that's what matters. Log the error and move on. ThunderHooks will retry automatically anyway.

Step 5: Verify Signatures on the Receiving End

Every webhook delivery includes three Standard Webhooks headers:

  • webhook-id — a unique ID for deduplication
  • webhook-timestamp — Unix timestamp of when the event was sent
  • webhook-signature — HMAC-SHA256 signature of the payload

The signature is computed over {webhook-id}.{webhook-timestamp}.{body} using the endpoint's signing secret.

Verification in Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
    "math"
    "net/http"
    "time"
)

func VerifyWebhookSignature(r *http.Request, body []byte, secret string) error {
    msgID := r.Header.Get("webhook-id")
    timestamp := r.Header.Get("webhook-timestamp")
    signature := r.Header.Get("webhook-signature")

    if msgID == "" || timestamp == "" || signature == "" {
        return fmt.Errorf("missing required webhook headers")
    }

    // Check timestamp to prevent replay attacks (5 minute tolerance)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp: %w", err)
    }
    diff := math.Abs(float64(time.Now().Unix() - ts))
    if diff > 300 {
        return fmt.Errorf("timestamp too old or too far in the future")
    }

    // Compute expected signature
    secretBytes, err := base64.StdEncoding.DecodeString(secret)
    if err != nil {
        return fmt.Errorf("invalid secret: %w", err)
    }

    toSign := fmt.Sprintf("%s.%s.%s", msgID, timestamp, string(body))
    mac := hmac.New(sha256.New, secretBytes)
    mac.Write([]byte(toSign))
    expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte("v1,"+expected), []byte(signature)) {
        return fmt.Errorf("signature mismatch")
    }

    return nil
}

Verification in Node.js

const crypto = require("crypto");

function verifyWebhookSignature(headers, body, secret) {
  const msgId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];

  if (!msgId || !timestamp || !signature) {
    throw new Error("Missing required webhook headers");
  }

  // Check timestamp (5 minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
    throw new Error("Timestamp too old or too far in the future");
  }

  // Compute expected signature
  const secretBytes = Buffer.from(secret, "base64");
  const toSign = `${msgId}.${timestamp}.${body}`;
  const expected = crypto
    .createHmac("sha256", secretBytes)
    .update(toSign)
    .digest("base64");

  const expectedSig = `v1,${expected}`;
  if (!crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(signature))) {
    throw new Error("Signature mismatch");
  }

  return true;
}

// Express middleware
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  try {
    verifyWebhookSignature(req.headers, req.body.toString(), process.env.WEBHOOK_SECRET);
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  const event = JSON.parse(req.body);
  console.log(`Received event: ${event.event_type}`);

  // Process the event...

  res.status(200).json({ received: true });
});

Two things to get right here. First, always use constant-time comparison (hmac.Equal in Go, timingSafeEqual in Node) to prevent timing attacks. Second, check the timestamp to reject replayed requests — a 5-minute window is standard.

Retry Behavior

When a delivery fails (non-2xx response or timeout), ThunderHooks retries with exponential backoff. The default schedule is:

Attempt Delay
1 Immediate
2 5 seconds
3 30 seconds
4 5 minutes
5 30 minutes
6 2 hours

After all attempts are exhausted, the event is marked as failed. Your customers can manually retry from the portal, and you can configure alert notifications so they know when deliveries are consistently failing.

Customers should use the webhook-id header for idempotency. If they receive the same webhook-id twice (because of a retry after a network issue where the first attempt actually succeeded), they should skip processing it a second time.

What You Just Built

In about 10 minutes, you added:

  • Outbound webhook delivery with automatic retries and exponential backoff
  • HMAC-SHA256 signing following the Standard Webhooks spec
  • Customer self-service for managing endpoints and viewing delivery logs
  • Deduplication via webhook-id headers

No message queues to run. No retry logic to maintain. No delivery dashboard to build.

Your customers get a reliable, standards-compliant webhook experience, and you get to focus on the features that actually differentiate your product.

Ready to get started? Sign up for ThunderHooks and have your first webhook delivered in minutes.

Ready to simplify webhook testing?

Try ThunderHooks free. No credit card required.

Get Started Free