Managing Webhook Endpoints

The receiver is where Sent delivers events. This page is the other side: the surface where your customers manage which endpoints exist — register a URL, enable or disable it, rotate its secret, remove it.

These are two distinct concerns living at two distinct paths, and it's worth being precise:

  • POST /webhooks/sent — the receiver. Sent calls it. Covered in The webhook receiver.
  • /api/webhooks* — management. Your client calls it. A thin proxy over the SDK's webhooks resource. That's this page.

The management routes proxy the SDK's webhooks.list / create / delete / rotateSecret / toggleStatus methods, mapping the SDK's snake_case fields to your camelCase contract.

Developing locally? Sent needs a publicly reachable HTTPS URL to deliver to — it can't reach localhost. Tunnel your local receiver with a tool like ngrok (ngrok http 3000) or ngrok's built-in webhook forwarding, register the tunnel's URL as your endpoint, and you'll see real, signed deliveries hit your machine while you develop. Swap in your real production URL before go-live — see the checklist.

Per-customer isolation

Every management call is authenticated by the caller's Authorization: Bearer <key>, and the SDK client is built per request from that key (see Authentication). The SDK's webhooks resource is scoped to the account behind the key, so a customer only ever sees and manages their own endpoints. There's no cross-customer spillage to guard against — the credential boundary handles it, as long as you never build the client from a shared, stored key.

The born-on-registration secret

This is the one thing to get right here.

The signing secret is returned exactly once, on create (and again on rotate). It is not an env var you set, and there is no API to read it back later. If you don't capture it at the moment of creation, you cannot verify that endpoint's deliveries — you'll have to rotate to get a fresh one.

So the create handler does two things atomically: register the endpoint via the SDK, and hand the returned secret to the store your receiver verifies against. Starting with that store as process memory (file-backed for PHP-FPM, which doesn't keep long-lived process state) is fine to begin with; in production it's your datastore, keyed by endpoint id.

Registering an endpoint

router.post("/", validateBody(CreateWebhookSchema), asyncHandler(async (req, res) => {
  const { sentService } = servicesForRequest(req);
  const body = req.body as { url: string; eventTypes?: string[] };
  const created = await sentService.createWebhook(body.url, body.eventTypes);
  // Hold the signing secret in memory, keyed by endpoint id, so the receiver
  // can look up the right secret for whichever endpoint Sent delivers to.
  webhookSecretStore.remember(created.id, created.secret);
  res.status(201).json(created);
}));

// In the service layer — note the snake_case → camelCase field mapping:
async createWebhook(url: string, eventTypes?: string[]) {
  const response = await this.client.webhooks.create({
    endpoint_url: url,          // contract `url`         → SDK `endpoint_url`
    event_types: eventTypes,    // contract `eventTypes`  → SDK `event_types`
  });
  const w = response.data ?? {};
  return {
    id: w.id,
    url: w.endpoint_url,
    eventTypes: w.event_types,
    active: w.is_active,        // SDK `is_active`        → contract `active`
    createdAt: w.created_at,
    secret: w.signing_secret,   // SDK `signing_secret`   → contract `secret` (ONCE)
  };
}
@router.post("", response_model=CanonicalCreatedWebhook, status_code=201)
async def create_webhook(request: Request, body: CanonicalCreateWebhookRequest,
                         client=Depends(get_sent_client)):
    service = SentService(client)
    created = await service.create_webhook(body.url, body.eventTypes)
    # Hold the signing secret in memory, keyed by endpoint id, so the receiver
    # can look up the right secret for whichever endpoint Sent delivers to.
    webhook_secret_store.remember(created.id, created.secret)
    return created

# In the service layer — snake_case (SDK) ↔ camelCase (contract):
#   url         → endpoint_url        is_active     → active
#   eventTypes  → event_types         signing_secret → secret (returned ONCE)
func (h *WebhookAdminHandler) Create(c echo.Context) error {
    var req models.CreateWebhookRequest // { URL, EventTypes }
    if err := c.Bind(&req); err != nil {
        return models.BindValidationError(err)
    }
    client, err := h.resolver.ForRequest(c) // per-request, key-scoped client
    if err != nil {
        return err
    }
    resp, err := client.WebhookCreate(c.Request().Context(), req.URL, req.EventTypes)
    if err != nil {
        return err
    }
    created := models.NewWebhookResponse(resp.Data) // maps endpoint_url→url, is_active→active…
    created.Secret = resp.Data.SigningSecret        // signing_secret → secret (ONCE)
    // Hold the signing secret in memory, keyed by endpoint id, so the receiver
    // can look up the right secret for whichever endpoint Sent delivers to.
    h.secrets.Remember(resp.Data.ID, resp.Data.SigningSecret)
    return c.JSON(http.StatusCreated, created)
}

Field mapping

The SDK / v3 API uses snake_case; your app's contract will likely use camelCase. Map at the edge:

Contract fieldSDK / v3 source
urlendpoint_url
eventTypesevent_types
activeis_active
createdAtcreated_at
secretsigning_secret

Listing, toggling, deleting

The rest of the surface is a straight pass-through to the SDK. List returns the caller's endpoints (never the secret — that's create/rotate only). Toggle flips is_active to disable an endpoint without deleting it. Delete removes it.

router.get("/", asyncHandler(async (req, res) => {
  const { sentService } = servicesForRequest(req);
  res.json({ items: await sentService.listWebhooks() });
}));

router.patch("/:id/toggle", asyncHandler(async (req, res) => {
  const { sentService } = servicesForRequest(req);
  res.json(await sentService.toggleWebhook(String(req.params.id)));
}));

router.delete("/:id", asyncHandler(async (req, res) => {
  const { sentService } = servicesForRequest(req);
  await sentService.deleteWebhook(String(req.params.id));
  res.status(204).send();
}));
@router.get("", response_model=CanonicalWebhookList)
async def list_webhooks(request: Request, client=Depends(get_sent_client)):
    return await SentService(client).list_webhooks()

@router.patch("/{webhook_id}/toggle", response_model=CanonicalWebhook)
async def toggle_webhook(request: Request, webhook_id: str, client=Depends(get_sent_client)):
    return await SentService(client).toggle_webhook(webhook_id)

@router.delete("/{webhook_id}", status_code=204)
async def delete_webhook(request: Request, webhook_id: str, client=Depends(get_sent_client)):
    await SentService(client).delete_webhook(webhook_id)
    return Response(status_code=204)
func (h *WebhookAdminHandler) Register(e *echo.Echo) {
    e.GET("/api/webhooks", h.List)
    e.POST("/api/webhooks", h.Create)
    e.PATCH("/api/webhooks/:id/toggle", h.Toggle)
    e.POST("/api/webhooks/:id/rotate-secret", h.RotateSecret)
    e.DELETE("/api/webhooks/:id", h.Delete)
}

func (h *WebhookAdminHandler) Toggle(c echo.Context) error {
    client, err := h.resolver.ForRequest(c)
    if err != nil {
        return err
    }
    resp, err := client.WebhookToggle(c.Request().Context(), c.Param("id"))
    if err != nil {
        return err
    }
    return c.JSON(http.StatusOK, models.NewWebhookResponse(resp.Data))
}

Rotating a secret without downtime

Rotation exists so you can replace a signing secret that may be compromised — or on a routine schedule — without dropping a single delivery. The mechanism ties directly to the verifier's accept-any behavior:

Call rotate. The SDK returns a new signing_secret.

Remember the new secret alongside the old one. For a window, both are valid.

Sent begins signing with the new secret and, during the transition, may send X-Webhook-Signature as a space-separated list carrying both signatures.

Your verifier accepts a delivery if any candidate secret matches any signature token — so nothing is rejected mid-rotation.

Once you're confident the old secret is fully retired, forget it.

router.post("/:id/rotate-secret", asyncHandler(async (req, res) => {
  const { sentService } = servicesForRequest(req);
  const id = String(req.params.id);
  const secret = await sentService.rotateWebhookSecret(id);
  // Add the new secret to this endpoint id's set — don't replace the old one.
  // The verifier tries every secret in the set until the old one is forgotten.
  webhookSecretStore.remember(id, secret);
  res.json({ secret });
}));
@router.post("/{webhook_id}/rotate-secret", response_model=CanonicalWebhookSecret)
async def rotate_webhook_secret(request: Request, webhook_id: str,
                                client=Depends(get_sent_client)):
    rotated = await SentService(client).rotate_webhook_secret(webhook_id)
    # Add the new secret to this endpoint id's set — don't replace the old one.
    # The verifier tries every secret in the set until the old one is forgotten.
    webhook_secret_store.remember(webhook_id, rotated.secret)
    return rotated
func (h *WebhookAdminHandler) RotateSecret(c echo.Context) error {
    client, err := h.resolver.ForRequest(c)
    if err != nil {
        return err
    }
    resp, err := client.WebhookRotateSecret(c.Request().Context(), c.Param("id"))
    if err != nil {
        return err
    }
    // Add the new secret to this endpoint id's set — don't replace the old one.
    // The verifier tries every secret in the set until the old one is forgotten.
    h.secrets.Remember(c.Param("id"), resp.Data.SigningSecret)
    return c.JSON(http.StatusOK, models.WebhookSecretResponse{Secret: resp.Data.SigningSecret})
}

This is exactly why the verifier holds a set of candidate secrets and iterates them. See the rotation section in Signature verification — the two halves are designed to fit together.

Where the secret lives

A simple starting point is holding the secret in process memory — except in PHP, where each request runs in a fresh FPM worker with no shared process state, so the secret store needs to be file-backed on disk instead. Both are stand-ins.

In production, persist the secret to your datastore keyed by the endpoint id, and load it per delivery in the receiver. Process memory doesn't survive a restart and isn't shared across instances — a second instance would reject every delivery it can't find a secret for. Never log the secret at any point in this flow.

Next steps

On this page