package rest import ( "context" "net/http" "time" "github.com/danielgtaylor/huma/v2" "github.com/sarathsp06/sparrow/internal/webhooks" "github.com/sarathsp06/sparrow/internal/webhooks/store" "github.com/sarathsp06/sparrow/pkg/access/httpauth" ) type createSubscriptionBody struct { WebhookID string `json:"webhook_id" required:"true" doc:"Webhook id (UUID) to deliver matching events to. Must belong to this consumer."` EventName string `json:"event_name" required:"true" doc:"Event type name to subscribe to, or \"*\" to receive every event in the consumer (catch-all). sparrow.* names only under the _sparrow consumer. One subscription per webhook, event name and consumer (409 on a duplicate)."` Headers map[string]string `json:"headers,omitempty" doc:"Extra HTTP headers sent with deliveries from this subscription. They override the webhook's headers of the same name; the webhook's secret_headers override both. Same name and value rules as webhook headers."` Method string `json:"method,omitempty" doc:"HTTP method used for deliveries from this subscription, e.g. PUT. Defaults to POST."` Timeout int `json:"timeout,omitempty" doc:"Request timeout for this subscription's deliveries, in seconds, overriding the webhook's request_timeout_seconds. 0 to 300; 0 (default) uses the webhook's."` TransformEnabled bool `json:"transform_enabled,omitempty" doc:"Whether to render transform_template into the delivered body instead of sending the default envelope. Defaults to false."` TransformTemplate string `json:"transform_template,omitempty" doc:"Go text/template rendered against the event to produce the delivered body, used when transform_enabled is true. See GET /v1/template-functions for available helpers; test it with POST /v1/subscriptions:testTemplate. A template that does not parse is rejected with 400; it is not rendered on save. Saving a new template records a version in the template history."` LabelFilters map[string]string `json:"label_filters,omitempty" doc:"Key/value pairs that must ALL be present in an event's labels for this subscription to receive it. Empty means match every event of event_name. Same limits as event labels (20 pairs, 64-character keys, 256-character values)."` OnTransformError string `json:"on_transform_error,omitempty" enum:"fail,fallback" doc:"What happens when transform_template fails to render. fail (default): the delivery fails with error category template_error, is not retried automatically, and can be retried once the template is fixed; it does not affect the webhook's health. fallback: the default envelope payload is sent instead, and the error is still recorded on the delivery."` TemplateMissingKey string `json:"template_missing_key,omitempty" enum:"error,zero" doc:"How transform_template reads a key the payload does not have. error (default): the render fails, so a field removed from the event schema cannot silently turn into \"\" in the body; read optional fields with index or dig (default only fills a key that is present but empty). zero: the key renders as \"\"."` } type createSubscriptionInput struct { Consumer string `path:"consumer" doc:"Consumer the webhook belongs to."` Body createSubscriptionBody } type subscriptionIDInput struct { Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."` SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."` } type SubscriptionItem struct { SubscriptionID string `json:"subscription_id" doc:"Subscription id (UUID)."` Consumer string `json:"consumer" doc:"Consumer this subscription belongs to."` WebhookID string `json:"webhook_id" doc:"Webhook this subscription delivers to."` EventName string `json:"event_name" doc:"Event type name this subscription matches, or \"*\" for catch-all."` Headers map[string]string `json:"headers,omitempty" doc:"Extra HTTP headers sent with deliveries from this subscription."` Method string `json:"method,omitempty" enum:"GET,POST,PUT,PATCH,DELETE" doc:"HTTP method used for deliveries from this subscription; absent means POST."` Timeout int `json:"timeout,omitempty" doc:"Request timeout override for this subscription's deliveries, in seconds; absent means the webhook's request_timeout_seconds."` TransformEnabled bool `json:"transform_enabled" doc:"Whether transform_template is rendered into the delivered payload."` TransformTemplate string `json:"transform_template,omitempty" doc:"Go text/template rendered against the event to produce the delivered body when transform_enabled is true."` LabelFilters map[string]string `json:"label_filters,omitempty" doc:"Key/value pairs that must all be present in an event's labels for this subscription to receive it."` OnTransformError string `json:"on_transform_error" enum:"fail,fallback" doc:"What happens when transform_template fails to render. fail (default): the delivery fails with error category template_error, is not retried automatically, and can be retried once the template is fixed; it does not affect the webhook's health. fallback: the default envelope payload is sent instead, and the error is still recorded on the delivery."` TemplateMissingKey string `json:"template_missing_key" enum:"error,zero" doc:"How transform_template reads a key the payload does not have. error (default): the render fails, so a field removed from the event schema cannot silently turn into \"\" in the body; read optional fields with index or dig (default only fills a key that is present but empty). zero: the key renders as \"\"."` Paused bool `json:"paused" doc:"Whether the subscription is paused. While paused, events still fan out to it, but each delivery is recorded with status paused and not attempted until retried. A pause never affects the webhook's health."` PausedAt *string `json:"paused_at,omitempty" format:"date-time" doc:"When the current pause began, RFC3339. Absent when not paused."` PausedReason string `json:"paused_reason,omitempty" doc:"Why the subscription was paused."` CreatedAt string `json:"created_at" format:"date-time" doc:"Creation timestamp, RFC3339."` UpdatedAt string `json:"updated_at" format:"date-time" doc:"Last-modified timestamp, RFC3339."` } type subscriptionOutput struct { Body SubscriptionItem } func toSubscriptionItem(s *store.EventSubscription) SubscriptionItem { item := SubscriptionItem{ SubscriptionID: s.ID.String(), Consumer: s.Consumer, WebhookID: s.WebhookID.String(), EventName: s.EventName, Headers: s.Headers, Method: s.Method, Timeout: s.Timeout, TransformEnabled: s.TransformEnabled, TransformTemplate: s.TransformTemplate, LabelFilters: s.LabelFilters, OnTransformError: s.OnTransformError, TemplateMissingKey: s.TemplateMissingKey, Paused: s.Paused(), PausedReason: s.PausedReason, CreatedAt: s.CreatedAt.Format(time.RFC3339Nano), UpdatedAt: s.UpdatedAt.Format(time.RFC3339Nano), } if s.PausedAt != nil { t := s.PausedAt.Format(time.RFC3339Nano) item.PausedAt = &t } return item } type pauseSubscriptionInput struct { Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."` SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."` Body struct { Reason string `json:"reason,omitempty" maxLength:"500" doc:"Why the subscription is paused, shown next to it, for example: receiver maintenance until Friday."` } } type resumeSubscriptionOutput struct { Body struct { SubscriptionItem PausedSince *string `json:"paused_since,omitempty" format:"date-time" doc:"When the pause that just ended began, RFC3339; absent when the subscription was not paused. List or retry what was held with status=paused&subscription_id=...&created_after=."` PausedDeliveries int `json:"paused_deliveries" doc:"Deliveries of this subscription currently in status paused. They are not sent automatically; retry them to deliver."` } } func toSubscriptionOutput(s *store.EventSubscription) *subscriptionOutput { return &subscriptionOutput{Body: toSubscriptionItem(s)} } // SubscriptionListParams are the query filters shared by the consumer-scoped // and global subscription list routes. type SubscriptionListParams struct { WebhookID string `query:"webhook_id,omitempty" doc:"Filter to subscriptions for one webhook (UUID). Takes precedence over event_name: when both are given, event_name is ignored."` EventName string `query:"event_name,omitempty" doc:"Filter to subscriptions with exactly this event_name (\"*\" matches only catch-all subscriptions)."` Limit int32 `query:"limit" default:"50" minimum:"1" maximum:"1000" doc:"Maximum items to return, 1 to 1000."` Offset int32 `query:"offset" default:"0" doc:"Number of items to skip, for pagination."` } type listSubscriptionsInput struct { Consumer string `path:"consumer" doc:"Consumer to list subscriptions in."` SubscriptionListParams } type listSubscriptionsGlobalInput struct { Consumer string `query:"consumer,omitempty" doc:"Filter to one consumer; omit to list subscriptions across all consumers."` SubscriptionListParams } type listSubscriptionsOutput struct { Body struct { Items []SubscriptionItem `json:"items" doc:"Subscriptions, newest first."` Pagination PaginationOutput `json:"pagination" doc:"Offset pagination with a total count."` } } // patchSubscriptionBody applies a partial update: only fields present in the // request JSON are changed. webhook_id and event_name are immutable — // delete and recreate the subscription to change either. type patchSubscriptionBody struct { Headers *map[string]string `json:"headers,omitempty" doc:"Replace all extra HTTP headers."` Method *string `json:"method,omitempty" doc:"Replace the HTTP method."` Timeout *int `json:"timeout,omitempty" doc:"Replace the request timeout override, in seconds (0 to 300; 0 uses the webhook's)."` TransformEnabled *bool `json:"transform_enabled,omitempty" doc:"Enable or disable payload transformation."` TransformTemplate *string `json:"transform_template,omitempty" doc:"Replace the transform template. A template that does not parse is rejected with 400. A change is recorded as a new version in the template history (the last 20 are kept)."` LabelFilters *map[string]string `json:"label_filters,omitempty" doc:"Replace all label filters; an empty object matches every event."` OnTransformError *string `json:"on_transform_error,omitempty" enum:"fail,fallback" doc:"Replace what happens when the template fails to render."` TemplateMissingKey *string `json:"template_missing_key,omitempty" enum:"error,zero" doc:"Replace how the template reads a missing key."` TemplateSource string `json:"template_source,omitempty" enum:"manual,ai_draft" doc:"How a changed transform_template was produced, recorded in the subscription's template history. Defaults to manual."` TemplateNotes string `json:"template_notes,omitempty" maxLength:"2000" doc:"Optional note stored with the template version, e.g. the AI drafter's summary."` } type templateVersionItem struct { VersionID string `json:"version_id" doc:"Version id (UUID)."` Template string `json:"template" doc:"The template text as saved."` Source string `json:"source" enum:"manual,ai_draft" doc:"How the template was produced: manual or ai_draft."` Notes string `json:"notes,omitempty" doc:"Note stored with the version."` SavedBy string `json:"saved_by,omitempty" doc:"Name of the master key or access token that saved it; absent when auth is off."` CreatedAt time.Time `json:"created_at" doc:"When it was saved, RFC3339."` Current bool `json:"current" doc:"True for the newest version, which is the subscription's current template."` } type listTemplateVersionsInput struct { Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."` SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."` Limit int `query:"limit" minimum:"1" maximum:"20" default:"20" doc:"Maximum versions to return, newest first, 1 to 20."` } type listTemplateVersionsOutput struct { Body struct { Items []templateVersionItem `json:"items" doc:"Saved versions, newest first."` } } type patchSubscriptionInput struct { Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."` SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."` Body patchSubscriptionBody } type testTemplateBody struct { EventName string `json:"event_name" required:"true" doc:"Registered event type. Its name is the envelope's event_name, and its sample_payload (generated from its schema; empty without one) is rendered against when payload is omitted."` Payload map[string]any `json:"payload,omitempty" required:"false" doc:"Payload to render against instead of the event type's generated sample, e.g. a real event's payload from listEventOccurrences. Used as given, even when empty; the event type needs no schema."` Template string `json:"template" required:"true" doc:"Go template to render, in the same syntax used by transform_template."` TemplateMissingKey string `json:"template_missing_key,omitempty" enum:"error,zero" required:"false" doc:"Render as a subscription with this template_missing_key would. Defaults to error, the subscription default."` } type testTemplateInput struct { Body testTemplateBody } type testTemplateOutput struct { Body struct { Rendered string `json:"rendered" doc:"The template output, rendered against payload (or the event type's sample payload) with a placeholder event id and the current time."` } } func registerSubscriptionRoutes(api huma.API, svc webhooks.SubscriptionManager) { huma.Register(api, huma.Operation{ OperationID: "createSubscription", Method: http.MethodPost, Path: "/v1/consumers/{consumer}/subscriptions", Summary: "Create a subscription linking a webhook to an event", Description: "Subscribes a webhook to an event type within a consumer, with an optional payload transform and label filters. Registering a webhook with events already creates one subscription per name; use this for additional, catch-all (\"*\") or per-subscription-template subscriptions. A non-empty transform_template is recorded as the first template version.\n\n" + "Not idempotent: 409 when the webhook already has a subscription for event_name. 404 when the webhook is not in this consumer. 400 for invalid headers, timeout or label filters, a sparrow.* event outside _sparrow, or a missing transform when the webhook has requires_transform.\n\n" + "Portal tokens: allowed, as POST /portal/api/subscriptions.", Errors: []int{400, 404, 409}, Tags: []string{"Subscriptions"}, DefaultStatus: http.StatusCreated, }, func(ctx context.Context, in *createSubscriptionInput) (*subscriptionOutput, error) { id, _, err := svc.CreateSubscription(ctx, in.Body.WebhookID, in.Body.EventName, in.Consumer, in.Body.Headers, in.Body.Method, in.Body.Timeout, in.Body.TransformEnabled, in.Body.TransformTemplate, in.Body.LabelFilters, webhooks.SubscriptionTemplateSettings{ OnTransformError: in.Body.OnTransformError, TemplateMissingKey: in.Body.TemplateMissingKey, }) if err != nil { return nil, mapError(ctx, err, "failed to create subscription") } sub, err := svc.GetSubscription(ctx, id, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to reload subscription") } return toSubscriptionOutput(sub), nil }) huma.Register(api, huma.Operation{ OperationID: "getSubscription", Method: http.MethodGet, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}", Summary: "Get a subscription by id", Description: "Returns one subscription: its webhook link, transform template and settings, label filters and pause state. 404 when it is not in this consumer.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions/{subscription_id}.", Errors: []int{404}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *subscriptionIDInput) (*subscriptionOutput, error) { sub, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to get subscription") } return toSubscriptionOutput(sub), nil }) huma.Register(api, huma.Operation{ OperationID: "listSubscriptions", Method: http.MethodGet, Path: "/v1/consumers/{consumer}/subscriptions", Summary: "List subscriptions", Description: "Lists subscriptions in a consumer, newest first, optionally filtered by webhook or event type name. Offset paginated with total_count. 404 when webhook_id is given and the webhook is not in this consumer.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions.", Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *listSubscriptionsInput) (*listSubscriptionsOutput, error) { return listSubscriptionsImpl(ctx, svc, in.Consumer, in.SubscriptionListParams) }) huma.Register(api, huma.Operation{ OperationID: "listSubscriptionsGlobal", Method: http.MethodGet, Path: "/v1/subscriptions", Summary: "List subscriptions across all consumers", Description: "Lists subscriptions across every consumer with the same filters and pagination as the per-consumer route. Pass consumer to scope to one consumer.\n\nPortal tokens: not allowed.", Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *listSubscriptionsGlobalInput) (*listSubscriptionsOutput, error) { return listSubscriptionsImpl(ctx, svc, in.Consumer, in.SubscriptionListParams) }) huma.Register(api, huma.Operation{ OperationID: "updateSubscription", Method: http.MethodPatch, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}", Summary: "Partially update a subscription", Description: "Partially updates a subscription's headers, method, timeout, transform, or label filters: only fields present in the body change, and map fields replace the whole map. The linked webhook_id and event_name cannot be changed; delete and recreate instead. A changed transform_template is recorded as a new template version with template_source and template_notes. Returns the updated subscription. Idempotent: repeating the same body records no new version.\n\n" + "400 for invalid values, or when the webhook has requires_transform and the result has no enabled template. 404 when it is not in this consumer.\n\nPortal tokens: allowed, as PATCH /portal/api/subscriptions/{subscription_id}.", Errors: []int{400, 404}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *patchSubscriptionInput) (*subscriptionOutput, error) { existing, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to get subscription") } headers := map[string]string(existing.Headers) if in.Body.Headers != nil { headers = *in.Body.Headers } method := existing.Method if in.Body.Method != nil { method = *in.Body.Method } timeout := existing.Timeout if in.Body.Timeout != nil { timeout = *in.Body.Timeout } transformEnabled := existing.TransformEnabled if in.Body.TransformEnabled != nil { transformEnabled = *in.Body.TransformEnabled } transformTemplate := existing.TransformTemplate if in.Body.TransformTemplate != nil { transformTemplate = *in.Body.TransformTemplate } labelFilters := map[string]string(existing.LabelFilters) if in.Body.LabelFilters != nil { labelFilters = *in.Body.LabelFilters } settings := webhooks.SubscriptionTemplateSettings{ OnTransformError: existing.OnTransformError, TemplateMissingKey: existing.TemplateMissingKey, } if in.Body.OnTransformError != nil { settings.OnTransformError = *in.Body.OnTransformError } if in.Body.TemplateMissingKey != nil { settings.TemplateMissingKey = *in.Body.TemplateMissingKey } meta := webhooks.TemplateSaveMeta{Source: in.Body.TemplateSource, Notes: in.Body.TemplateNotes} // Record who saved it only for a real credential; with auth off the // middleware supplies an anonymous placeholder that is not worth storing. if p, ok := httpauth.FromContext(ctx); ok && (p.Root || p.TokenID != "") { meta.SavedBy = p.Name } if err := svc.UpdateSubscription(ctx, in.SubscriptionID, in.Consumer, headers, method, timeout, transformEnabled, transformTemplate, labelFilters, settings, meta); err != nil { return nil, mapError(ctx, err, "failed to update subscription") } updated, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to reload subscription") } return toSubscriptionOutput(updated), nil }) huma.Register(api, huma.Operation{ OperationID: "pauseSubscription", Method: http.MethodPost, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}:pause", Summary: "Pause a subscription", Description: "Stops the subscription's deliveries from being attempted, for any reason (receiver maintenance, a template being fixed, an investigation). Events keep fanning out to it: each delivery is recorded with status paused and nothing is queued, so nothing is lost. Queued or retrying deliveries of the subscription are held as paused when their turn comes. A pause never affects the webhook's health. Returns the subscription (200), unlike webhook pause, which returns 204.\n\n" + "Idempotent: pausing again updates the reason and keeps the original pause time. 400 for a reason over 500 characters, 404 when it is not in this consumer.\n\nPortal tokens: allowed, as POST /portal/api/subscriptions/{subscription_id}:pause.", Errors: []int{400, 404}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *pauseSubscriptionInput) (*subscriptionOutput, error) { sub, err := svc.PauseSubscription(ctx, in.SubscriptionID, in.Consumer, in.Body.Reason) if err != nil { return nil, mapError(ctx, err, "failed to pause subscription") } return toSubscriptionOutput(sub), nil }) huma.Register(api, huma.Operation{ OperationID: "resumeSubscription", Method: http.MethodPost, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}:resume", Summary: "Resume a paused subscription", Description: "Lets new deliveries be attempted again. Deliveries recorded while paused stay paused and are never sent automatically: the response says how many there are and when the pause began, so you can retry all of them or only those since a time with the delivery retry endpoints. Idempotent: resuming a subscription that is not paused succeeds with no paused_since.\n\n" + "Portal tokens: allowed, as POST /portal/api/subscriptions/{subscription_id}:resume.", Errors: []int{404}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *subscriptionIDInput) (*resumeSubscriptionOutput, error) { res, err := svc.ResumeSubscription(ctx, in.SubscriptionID, in.Consumer) if err != nil { return nil, mapError(ctx, err, "failed to resume subscription") } out := &resumeSubscriptionOutput{} out.Body.SubscriptionItem = toSubscriptionItem(res.Subscription) out.Body.PausedDeliveries = res.PausedDeliveries if res.PausedSince != nil { t := res.PausedSince.Format(time.RFC3339Nano) out.Body.PausedSince = &t } return out, nil }) huma.Register(api, huma.Operation{ OperationID: "listSubscriptionTemplateVersions", Method: http.MethodGet, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}/templateVersions", Summary: "List a subscription's saved template history", Description: "Returns the transform templates this subscription has been saved with, newest first; the newest is the current one. A version is recorded whenever a save changes the template, with how it was produced (manual or ai_draft), an optional note, and who saved it. The last 20 are kept. There is no restore endpoint: load a version into the editor and save the subscription, which records it as a new version.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions/{subscription_id}/templateVersions.", Errors: []int{404}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *listTemplateVersionsInput) (*listTemplateVersionsOutput, error) { versions, err := svc.ListSubscriptionTemplateVersions(ctx, in.SubscriptionID, in.Consumer, in.Limit) if err != nil { return nil, mapError(ctx, err, "failed to list template versions") } out := &listTemplateVersionsOutput{} out.Body.Items = make([]templateVersionItem, 0, len(versions)) for i, v := range versions { out.Body.Items = append(out.Body.Items, templateVersionItem{ VersionID: v.ID.String(), Template: v.Template, Source: v.Source, Notes: v.Notes, SavedBy: v.SavedBy, CreatedAt: v.CreatedAt, Current: i == 0, }) } return out, nil }) huma.Register(api, huma.Operation{ OperationID: "deleteSubscription", Method: http.MethodDelete, Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}", Summary: "Delete a subscription", Description: "Deletes the subscription and its template history. The webhook stops receiving that event's occurrences; delivery history is kept. Cannot be undone. A second delete returns 404.\n\nPortal tokens: allowed, as DELETE /portal/api/subscriptions/{subscription_id}.", Errors: []int{404}, Tags: []string{"Subscriptions"}, DefaultStatus: http.StatusNoContent, }, func(ctx context.Context, in *subscriptionIDInput) (*emptyOutput, error) { if err := svc.DeleteSubscription(ctx, in.SubscriptionID, in.Consumer); err != nil { return nil, mapError(ctx, err, "failed to delete subscription") } return &emptyOutput{Status: http.StatusNoContent}, nil }) huma.Register(api, huma.Operation{ OperationID: "testSubscriptionTemplate", Method: http.MethodPost, Path: "/v1/subscriptions:testTemplate", Summary: "Render a transform template against a sample or given payload", Description: "Dry-runs a transform template against payload when given, else the named event type's generated sample payload, without creating a subscription or any delivery. Use this to iterate on transform_template before saving it; pass a real event's payload to see what a receiver would get. Stateless and idempotent.\n\n" + "400 when the template does not parse or fails to render (with template_missing_key error, a key the payload lacks is an error), 404 when the event type is not registered, 503 when the template engine is at capacity (retry).\n\nPortal tokens: allowed, as POST /portal/api/subscriptions:testTemplate.", Errors: []int{400, 404, 503}, Tags: []string{"Subscriptions"}, }, func(ctx context.Context, in *testTemplateInput) (*testTemplateOutput, error) { rendered, err := svc.TestSubscriptionTemplate(ctx, in.Body.EventName, in.Body.Template, "", in.Body.TemplateMissingKey != store.TemplateMissingKeyZero, in.Body.Payload) if err != nil { return nil, mapError(ctx, err, "failed to test template") } out := &testTemplateOutput{} out.Body.Rendered = rendered return out, nil }) } // listSubscriptionsImpl is the shared body of the consumer-scoped and global // subscription list routes. An empty consumer lists across all consumers. func listSubscriptionsImpl(ctx context.Context, svc webhooks.SubscriptionManager, consumer string, p SubscriptionListParams) (*listSubscriptionsOutput, error) { subs, total, err := svc.ListSubscriptions(ctx, consumer, p.WebhookID, p.EventName, p.Limit, p.Offset) if err != nil { return nil, mapError(ctx, err, "failed to list subscriptions") } out := &listSubscriptionsOutput{} out.Body.Items = make([]SubscriptionItem, 0, len(subs)) for _, s := range subs { out.Body.Items = append(out.Body.Items, toSubscriptionItem(s)) } out.Body.Pagination = newPagination(p.Limit, p.Offset, total) return out, nil }