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, turn it on or off, 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.

Samples on this page are shown in TypeScript, Python, and Go; the patterns are framework-agnostic and transfer directly to the other supported SDK languages.

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. For what that store should be, see Where the secret lives.

Registering an endpoint

In the TypeScript sample, validateBody, asyncHandler, and webhookSecretStore are the shared helpers defined in project setup.

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 turn off 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

Rotation exists so you can replace a signing secret that may be compromised, or replace it on a routine schedule. There is no overlap window: the old secret is invalidated the moment the rotate call returns, and Sent signs every subsequent delivery with the new secret only (always exactly one signature per delivery). Treat rotation like a deployment: the faster the new secret reaches the store your receiver verifies against, the fewer deliveries fail in the gap.

Call rotate. The SDK returns a new signing_secret (shown once, like on create), and the old secret is already invalid.

Persist the new secret immediately, in the same handler. Every delivery from now on is signed with it; a delivery checked only against the old secret is rejected.

Rejected deliveries aren't lost. Sent retries failed deliveries, so a short gap recovers on its own. But repeated failures count against the endpoint (10 consecutive failures auto-disable it), so treat rotate-and-persist as one atomic move, not two tasks.

Retire the old secret from the endpoint's candidate set on your own schedule. It never matches again, so leaving it briefly is harmless; remove it once deliveries verify against the new secret.

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);
  // Persist the new secret immediately — Sent already signs with it. The old
  // entry in the set is harmless now; retire it once new-secret deliveries verify.
  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)
    # Persist the new secret immediately — Sent already signs with it. The old
    # entry in the set is harmless now; retire it once new-secret deliveries verify.
    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
    }
    // Persist the new secret immediately — Sent already signs with it. The old
    // entry in the set is harmless now; retire it once new-secret deliveries verify.
    h.secrets.Remember(c.Param("id"), resp.Data.SigningSecret)
    return c.JSON(http.StatusOK, models.WebhookSecretResponse{Secret: resp.Data.SigningSecret})
}

The verifier holds a set of candidate secrets per endpoint so that adding the new secret and retiring the old one don't have to be coordinated in a single step. See the rotation section in Signature verification.

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