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'swebhooksresource. 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 field | SDK / v3 source |
|---|---|
url | endpoint_url |
eventTypes | event_types |
active | is_active |
createdAt | created_at |
secret | signing_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 rotatedfunc (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
Signature Verification
The exact Svix-style HMAC scheme Sent uses to sign webhooks — decode the secret, sign id.timestamp.body, constant-time compare, enforce the replay window, and accept-any during rotation. Verbatim verifiers in seven languages.
Tracking Delivery Status
Close the loop — turn the stream of webhook events into a queryable per-message status, using a forward-only, idempotent store, and expose it as GET /api/messages/:id.