Repository: svix/svix-webhooks (language: rust)

Issue:
Server: change use of Axum `Extension` with `State`
We probably want to just pass this state to the worker too, and have a global state of connection pools, etc.
Fix `wait-for` to have a short timeout (e.g 2s) for each connection attempt
The problem that we are facing now (without it), is that if the first attempt hangs for whatever reason, the whole wait for would fail, even though trying again would have worked just fine.

It's a simple fix.


Discussion / hints:

Fix patch:
diff --git a/.version b/.version
index 67d5f802f..c52842c46 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.72.0
\ No newline at end of file
+0.75.0
diff --git a/ChangeLog.md b/ChangeLog.md
index 659c0bc13..b384c7f48 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -3,6 +3,29 @@
 ## Next
 * 
 
+## Version 0.75.0
+* Server: add built in SSRF support (no need for an external proxy)
+* Server: many worker improvements (see #704 for details)
+* Server: fix Span HTTP Routes
+* Server: update deps
+* Server: add Length limit validation to EndpointHeader value
+* Server: add missing root CAs to default docker image
+* Server: add updated app-portal-access API endpoint (to replace the deprecated dashboard-access)
+* Server: add feature flag gating to event types
+* Libs/Rust: Enable TLS support in `reqwest` by default
+* Libs: support the new feature flag gating of event types
+
+## Version 0.74.1
+* Libs/Rust: make all `*Options` and fields public (missing from previous release)
+
+## Version 0.74.0
+* Libs/Rust: make `ListOptions` and `PostOptions` fields public
+* Libs/Rust: fix Webhook verification to accept &str
+
+## Version 0.73.0
+* Server: fix issue with potentially incorrect signatures for very small payloads.
+* Server: fix Docker image to use `exec` so that svix-server will accept container process signals.
+
 ## Version 0.72.0
 * Server: add metadata field to Endpoint endpoints
 * Server: add missing msg id index causing some queries to be slow
diff --git a/csharp/Svix/Authentication.cs b/csharp/Svix/Authentication.cs
index c6f3de4a3..03c4c3a9a 100644
--- a/csharp/Svix/Authentication.cs
+++ b/csharp/Svix/Authentication.cs
@@ -20,6 +20,51 @@ public Authentication(ISvixClient svixClient, IAuthenticationApi authenticationA
             _authenticationApi = authenticationApi ?? throw new ArgumentNullException(nameof(authenticationApi));
         }
 
+        public AppPortalAccessOut GetAppPortalAccess(string appId, AppPortalAccessIn appPortalAccess, string idempotencyKey = default)
+        {
+            try
+            {
+                var lMessage = _authenticationApi.GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPost(
+                    appId,
+                    appPortalAccess,
+                    idempotencyKey);
+
+                return lMessage;
+            }
+            catch (ApiException e)
+            {
+                Logger?.LogError(e, $"{nameof(GetAppPortalAccess)} failed");
+
+                if (Throw)
+                    throw;
+
+                return null;
+            }
+        }
+
+        public async Task<AppPortalAccessOut> GetAppPortalAccessAsync(string appId, AppPortalAccessIn appPortalAccess,
+            string idempotencyKey = default, CancellationToken cancellationToken = default)
+        {
+            try
+            {
+                var lMessage = await _authenticationApi.GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostAsync(
+                    appId,
+                    appPortalAccess,
+                    idempotencyKey);
+
+                return lMessage;
+            }
+            catch (ApiException e)
+            {
+                Logger?.LogError(e, $"{nameof(GetAppPortalAccessAsync)} failed");
+
+                if (Throw)
+                    throw;
+
+                return null;
+            }
+        }
+
         public DashboardAccessOut GetDashboardAccess(string appId, string idempotencyKey = default)
         {
             try
diff --git a/csharp/Svix/Svix.csproj b/csharp/Svix/Svix.csproj
index dad91e9e4..3abe029e6 100644
--- a/csharp/Svix/Svix.csproj
+++ b/csharp/Svix/Svix.csproj
@@ -3,7 +3,7 @@
   <PropertyGroup>
     <TargetFrameworks>net5.0;netstandard2.0</TargetFrameworks>
     <PackageId>Svix</PackageId>
-    <Version>0.72.0</Version>
+    <Version>0.75.0</Version>
     <Authors>Svix</Authors>
     <Company>Svix</Company>
     <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
diff --git a/go/authentication.go b/go/authentication.go
index 45c35dece..49d962112 100644
--- a/go/authentication.go
+++ b/go/authentication.go
@@ -10,7 +10,31 @@ type Authentication struct {
 	api *openapi.APIClient
 }
 
-type DashboardAccessOut openapi.DashboardAccessOut
+type (
+	AppPortalAccessIn  openapi.AppPortalAccessIn
+	AppPortalAccessOut openapi.AppPortalAccessOut
+	DashboardAccessOut openapi.DashboardAccessOut
+)
+
+func (a *Authentication) AppPortalAccess(appId string, appPortalAccessIn *AppPortalAccessIn) (*AppPortalAccessOut, error) {
+	return a.AppPortalAccessWithOptions(appId, appPortalAccessIn, nil)
+}
+
+func (a *Authentication) AppPortalAccessWithOptions(appId string, appPortalAccessIn *AppPortalAccessIn, options *PostOptions) (*AppPortalAccessOut, error) {
+	req := a.api.AuthenticationApi.GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPost(context.Background(), appId)
+	req = req.AppPortalAccessIn(openapi.AppPortalAccessIn(*appPortalAccessIn))
+	if options != nil {
+		if options.IdempotencyKey != nil {
+			req = req.IdempotencyKey(*options.IdempotencyKey)
+		}
+	}
+	out, res, err := req.Execute()
+	if err != nil {
+		return nil, wrapError(err, res)
+	}
+	ret := AppPortalAccessOut(out)
+	return &ret, nil
+}
 
 func (a *Authentication) DashboardAccess(appId string) (*DashboardAccessOut, error) {
 	return a.DashboardAccessWithOptions(appId, nil)
diff --git a/go/internal/openapi/api_authentication.go b/go/internal/openapi/api_authentication.go
index 3251d4616..83fb50f6b 100644
--- a/go/internal/openapi/api_authentication.go
+++ b/go/internal/openapi/api_authentication.go
@@ -205,6 +205,195 @@ func (a *AuthenticationApiService) ExpireAllApiV1AuthAppAppIdExpireAllPostExecut
 	return localVarHTTPResponse, nil
 }
 
+type ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest struct {
+	ctx _context.Context
+	ApiService *AuthenticationApiService
+	appId string
+	appPortalAccessIn *AppPortalAccessIn
+	idempotencyKey *string
+}
+
+func (r ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest) AppPortalAccessIn(appPortalAccessIn AppPortalAccessIn) ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest {
+	r.appPortalAccessIn = &appPortalAccessIn
+	return r
+}
+func (r ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest) IdempotencyKey(idempotencyKey string) ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest {
+	r.idempotencyKey = &idempotencyKey
+	return r
+}
+
+func (r ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest) Execute() (AppPortalAccessOut, *_nethttp.Response, error) {
+	return r.ApiService.GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostExecute(r)
+}
+
+/*
+ * GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPost Get Consumer App Portal Access
+ * Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.
+ * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
+ * @param appId
+ * @return ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest
+ */
+func (a *AuthenticationApiService) GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPost(ctx _context.Context, appId string) ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest {
+	return ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest{
+		ApiService: a,
+		ctx: ctx,
+		appId: appId,
+	}
+}
+
+/*
+ * Execute executes the request
+ * @return AppPortalAccessOut
+ */
+func (a *AuthenticationApiService) GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostExecute(r ApiGetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostRequest) (AppPortalAccessOut, *_nethttp.Response, error) {
+	var (
+		localVarHTTPMethod   = _nethttp.MethodPost
+		localVarPostBody     interface{}
+		localVarFormFileName string
+		localVarFileName     string
+		localVarFileBytes    []byte
+		localVarReturnValue  AppPortalAccessOut
+	)
+
+	localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "AuthenticationApiService.GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPost")
+	if err != nil {
+		return localVarReturnValue, nil, GenericOpenAPIError{error: err.Error()}
+	}
+
+	localVarPath := localBasePath + "/api/v1/auth/app-portal-access/{app_id}/"
+	localVarPath = strings.Replace(localVarPath, "{"+"app_id"+"}", _neturl.PathEscape(parameterToString(r.appId, "")), -1)
+
+	localVarHeaderParams := make(map[string]string)
+	localVarQueryParams := _neturl.Values{}
+	localVarFormParams := _neturl.Values{}
+	if strlen(r.appId) < 1 {
+		return localVarReturnValue, nil, reportError("appId must have at least 1 elements")
+	}
+	if strlen(r.appId) > 256 {
+		return localVarReturnValue, nil, reportError("appId must have less than 256 elements")
+	}
+	if r.appPortalAccessIn == nil {
+		return localVarReturnValue, nil, reportError("appPortalAccessIn is required and must be specified")
+	}
+
+	// to determine the Content-Type header
+	localVarHTTPContentTypes := []string{"application/json"}
+
+	// set Content-Type header
+	localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
+	if localVarHTTPContentType != "" {
+		localVarHeaderParams["Content-Type"] = localVarHTTPContentType
+	}
+
+	// to determine the Accept header
+	localVarHTTPHeaderAccepts := []string{"application/json"}
+
+	// set Accept header
+	localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
+	if localVarHTTPHeaderAccept != "" {
+		localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
+	}
+	if r.idempotencyKey != nil {
+		localVarHeaderParams["idempotency-key"] = parameterToString(*r.idempotencyKey, "")
+	}
+	// body params
+	localVarPostBody = r.appPortalAccessIn
+	req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes)
+	if err != nil {
+		return localVarReturnValue, nil, err
+	}
+
+	localVarHTTPResponse, err := a.client.callAPI(req)
+	if err != nil || localVarHTTPResponse == nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body)
+	localVarHTTPResponse.Body.Close()
+	localVarHTTPResponse.Body = _ioutil.NopCloser(bytes.NewBuffer(localVarBody))
+	if err != nil {
+		return localVarReturnValue, localVarHTTPResponse, err
+	}
+
+	if localVarHTTPResponse.StatusCode >= 300 {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: localVarHTTPResponse.Status,
+		}
+		if localVarHTTPResponse.StatusCode == 401 {
+			var v HttpErrorOut
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 403 {
+			var v HttpErrorOut
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 404 {
+			var v HttpErrorOut
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 409 {
+			var v HttpErrorOut
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 422 {
+			var v HTTPValidationError
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+			return localVarReturnValue, localVarHTTPResponse, newErr
+		}
+		if localVarHTTPResponse.StatusCode == 429 {
+			var v HttpErrorOut
+			err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+			if err != nil {
+				newErr.error = err.Error()
+				return localVarReturnValue, localVarHTTPResponse, newErr
+			}
+			newErr.model = v
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
+	if err != nil {
+		newErr := GenericOpenAPIError{
+			body:  localVarBody,
+			error: err.Error(),
+		}
+		return localVarReturnValue, localVarHTTPResponse, newErr
+	}
+
+	return localVarReturnValue, localVarHTTPResponse, nil
+}
+
 type ApiGetDashboardAccessApiV1AuthDashboardAccessAppIdPostRequest struct {
 	ctx _context.Context
 	ApiService *AuthenticationApiService
@@ -223,7 +412,9 @@ func (r ApiGetDashboardAccessApiV1AuthDashboardAccessAppIdPostRequest) Execute()
 
 /*
  * GetDashboardAccessApiV1AuthDashboardAccessAppIdPost Get Consumer App Portal Access
- * Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.
+ * DEPRECATED: Please use `app-portal-access` instead.
+
+Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.
  * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
  * @param appId
  * @return ApiGetDashboardAccessApiV1AuthDashboardAccessAppIdPostRequest
diff --git a/go/internal/openapi/model_app_portal_access_in.go b/go/internal/openapi/model_app_portal_access_in.go
new file mode 100644
index 000000000..d6a581de8
--- /dev/null
+++ b/go/internal/openapi/model_app_portal_access_in.go
@@ -0,0 +1,116 @@
+/*
+ * Svix API
+ *
+ * Welcome to the Svix API documentation!  Useful links: [Homepage](https://www.svix.com) | [Support email](mailto:support+docs@svix.com) | [Blog](https://www.svix.com/blog/) | [Slack Community](https://www.svix.com/slack/)  # Introduction  This is the reference documentation and schemas for the [Svix webhook service](https://www.svix.com) API. For tutorials and other documentation please refer to [the documentation](https://docs.svix.com).  ## Main concepts  In Svix you have four important entities you will be interacting with:  - `messages`: these are the webhooks being sent. They can have contents and a few other properties. - `application`: this is where `messages` are sent to. Usually you want to create one application for each user on your platform. - `endpoint`: endpoints are the URLs messages will be sent to. Each application can have multiple `endpoints` and each message sent to that application will be sent to all of them (unless they are not subscribed to the sent event type). - `event-type`: event types are identifiers denoting the type of the message being sent. Event types are primarily used to decide which events are sent to which endpoint.   ## Authentication  Get your authentication token (`AUTH_TOKEN`) from the [Svix dashboard](https://dashboard.svix.com) and use it as part of the `Authorization` header as such: `Authorization: Bearer ${AUTH_TOKEN}`.  <SecurityDefinitions />   ## Code samples  The code samples assume you already have the respective libraries installed and you know how to use them. For the latest information on how to do that, please refer to [the documentation](https://docs.svix.com/).   ## Idempotency  Svix supports [idempotency](https://en.wikipedia.org/wiki/Idempotence) for safely retrying requests without accidentally performing the same operation twice. This is useful when an API call is disrupted in transit and you do not receive a response.  To perform an idempotent request, pass the idempotency key in the `Idempotency-Key` header to the request. The idempotency key should be a unique value generated by the client. You can create the key in however way you like, though we suggest using UUID v4, or any other string with enough entropy to avoid collisions.  Svix's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key for any successful request. Subsequent requests with the same key return the same result.  Please note that idempotency is only supported for `POST` requests.   ## Cross-Origin Resource Sharing  This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/). And that allows cross-domain communication from the browser. All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. 
+ *
+ * API version: 1.4
+ */
+
+// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+
+package openapi
+
+import (
+	"encoding/json"
+)
+
+// AppPortalAccessIn struct for AppPortalAccessIn
+type AppPortalAccessIn struct {
+	FeatureFlags []string `json:"featureFlags,omitempty"`
+}
+
+// NewAppPortalAccessIn instantiates a new AppPortalAccessIn object
+// This constructor will assign default values to properties that have it defined,
+// and makes sure properties required by API are set, but the set of arguments
+// will change when the set of required properties is changed
+func NewAppPortalAccessIn() *AppPortalAccessIn {
+	this := AppPortalAccessIn{}
+	return &this
+}
+
+// NewAppPortalAccessInWithDefaults instantiates a new AppPortalAccessIn object
+// This constructor will only assign default values to properties that have it defined,
+// but it doesn't guarantee that properties required by API are set
+func NewAppPortalAccessInWithDefaults() *AppPortalAccessIn {
+	this := AppPortalAccessIn{}
+	return &this
+}
+
+// GetFeatureFlags returns the FeatureFlags field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *AppPortalAccessIn) GetFeatureFlags() []string {
+	if o == nil  {
+		var ret []string
+		return ret
+	}
+	return o.FeatureFlags
+}
+
+// GetFeatureFlagsOk returns a tuple with the FeatureFlags field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *AppPortalAccessIn) GetFeatureFlagsOk() (*[]string, bool) {
+	if o == nil || o.FeatureFlags == nil {
+		return nil, false
+	}
+	return &o.FeatureFlags, true
+}
+
+// HasFeatureFlags returns a boolean if a field has been set.
+func (o *AppPortalAccessIn) HasFeatureFlags() bool {
+	if o != nil && o.FeatureFlags != nil {
+		return true
+	}
+
+	return false
+}
+
+// SetFeatureFlags gets a reference to the given []string and assigns it to the FeatureFlags field.
+func (o *AppPortalAccessIn) SetFeatureFlags(v []string) {
+	o.FeatureFlags = v
+}
+
+func (o AppPortalAccessIn) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+	if o.FeatureFlags != nil {
+		toSerialize["featureFlags"] = o.FeatureFlags
+	}
+	return json.Marshal(toSerialize)
+}
+
+type NullableAppPortalAccessIn struct {
+	value *AppPortalAccessIn
+	isSet bool
+}
+
+func (v NullableAppPortalAccessIn) Get() *AppPortalAccessIn {
+	return v.value
+}
+
+func (v *NullableAppPortalAccessIn) Set(val *AppPortalAccessIn) {
+	v.value = val
+	v.isSet = true
+}
+
+func (v NullableAppPortalAccessIn) IsSet() bool {
+	return v.isSet
+}
+
+func (v *NullableAppPortalAccessIn) Unset() {
+	v.value = nil
+	v.isSet = false
+}
+
+func NewNullableAppPortalAccessIn(val *AppPortalAccessIn) *NullableAppPortalAccessIn {
+	return &NullableAppPortalAccessIn{value: val, isSet: true}
+}
+
+func (v NullableAppPortalAccessIn) MarshalJSON() ([]byte, error) {
+	return json.Marshal(v.value)
+}
+
+func (v *NullableAppPortalAccessIn) UnmarshalJSON(src []byte) error {
+	v.isSet = true
+	return json.Unmarshal(src, &v.value)
+}
+
+
diff --git a/go/internal/openapi/model_app_portal_access_out.go b/go/internal/openapi/model_app_portal_access_out.go
new file mode 100644
index 000000000..d3adb4768
--- /dev/null
+++ b/go/internal/openapi/model_app_portal_access_out.go
@@ -0,0 +1,137 @@
+/*
+ * Svix API
+ *
+ * Welcome to the Svix API documentation!  Useful links: [Homepage](https://www.svix.com) | [Support email](mailto:support+docs@svix.com) | [Blog](https://www.svix.com/blog/) | [Slack Community](https://www.svix.com/slack/)  # Introduction  This is the reference documentation and schemas for the [Svix webhook service](https://www.svix.com) API. For tutorials and other documentation please refer to [the documentation](https://docs.svix.com).  ## Main concepts  In Svix you have four important entities you will be interacting with:  - `messages`: these are the webhooks being sent. They can have contents and a few other properties. - `application`: this is where `messages` are sent to. Usually you want to create one application for each user on your platform. - `endpoint`: endpoints are the URLs messages will be sent to. Each application can have multiple `endpoints` and each message sent to that application will be sent to all of them (unless they are not subscribed to the sent event type). - `event-type`: event types are identifiers denoting the type of the message being sent. Event types are primarily used to decide which events are sent to which endpoint.   ## Authentication  Get your authentication token (`AUTH_TOKEN`) from the [Svix dashboard](https://dashboard.svix.com) and use it as part of the `Authorization` header as such: `Authorization: Bearer ${AUTH_TOKEN}`.  <SecurityDefinitions />   ## Code samples  The code samples assume you already have the respective libraries installed and you know how to use them. For the latest information on how to do that, please refer to [the documentation](https://docs.svix.com/).   ## Idempotency  Svix supports [idempotency](https://en.wikipedia.org/wiki/Idempotence) for safely retrying requests without accidentally performing the same operation twice. This is useful when an API call is disrupted in transit and you do not receive a response.  To perform an idempotent request, pass the idempotency key in the `Idempotency-Key` header to the request. The idempotency key should be a unique value generated by the client. You can create the key in however way you like, though we suggest using UUID v4, or any other string with enough entropy to avoid collisions.  Svix's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key for any successful request. Subsequent requests with the same key return the same result.  Please note that idempotency is only supported for `POST` requests.   ## Cross-Origin Resource Sharing  This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/). And that allows cross-domain communication from the browser. All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. 
+ *
+ * API version: 1.4
+ */
+
+// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+
+package openapi
+
+import (
+	"encoding/json"
+)
+
+// AppPortalAccessOut struct for AppPortalAccessOut
+type AppPortalAccessOut struct {
+	Token string `json:"token"`
+	Url string `json:"url"`
+}
+
+// NewAppPortalAccessOut instantiates a new AppPortalAccessOut object
+// This constructor will assign default values to properties that have it defined,
+// and makes sure properties required by API are set, but the set of arguments
+// will change when the set of required properties is changed
+func NewAppPortalAccessOut(token string, url string) *AppPortalAccessOut {
+	this := AppPortalAccessOut{}
+	this.Token = token
+	this.Url = url
+	return &this
+}
+
+// NewAppPortalAccessOutWithDefaults instantiates a new AppPortalAccessOut object
+// This constructor will only assign default values to properties that have it defined,
+// but it doesn't guarantee that properties required by API are set
+func NewAppPortalAccessOutWithDefaults() *AppPortalAccessOut {
+	this := AppPortalAccessOut{}
+	return &this
+}
+
+// GetToken returns the Token field value
+func (o *AppPortalAccessOut) GetToken() string {
+	if o == nil {
+		var ret string
+		return ret
+	}
+
+	return o.Token
+}
+
+// GetTokenOk returns a tuple with the Token field value
+// and a boolean to check if the value has been set.
+func (o *AppPortalAccessOut) GetTokenOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return &o.Token, true
+}
+
+// SetToken sets field value
+func (o *AppPortalAccessOut) SetToken(v string) {
+	o.Token = v
+}
+
+// GetUrl returns the Url field value
+func (o *AppPortalAccessOut) GetUrl() string {
+	if o == nil {
+		var ret string
+		return ret
+	}
+
+	return o.Url
+}
+
+// GetUrlOk returns a tuple with the Url field value
+// and a boolean to check if the value has been set.
+func (o *AppPortalAccessOut) GetUrlOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return &o.Url, true
+}
+
+// SetUrl sets field value
+func (o *AppPortalAccessOut) SetUrl(v string) {
+	o.Url = v
+}
+
+func (o AppPortalAccessOut) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+	if true {
+		toSerialize["token"] = o.Token
+	}
+	if true {
+		toSerialize["url"] = o.Url
+	}
+	return json.Marshal(toSerialize)
+}
+
+type NullableAppPortalAccessOut struct {
+	value *AppPortalAccessOut
+	isSet bool
+}
+
+func (v NullableAppPortalAccessOut) Get() *AppPortalAccessOut {
+	return v.value
+}
+
+func (v *NullableAppPortalAccessOut) Set(val *AppPortalAccessOut) {
+	v.value = val
+	v.isSet = true
+}
+
+func (v NullableAppPortalAccessOut) IsSet() bool {
+	return v.isSet
+}
+
+func (v *NullableAppPortalAccessOut) Unset() {
+	v.value = nil
+	v.isSet = false
+}
+
+func NewNullableAppPortalAccessOut(val *AppPortalAccessOut) *NullableAppPortalAccessOut {
+	return &NullableAppPortalAccessOut{value: val, isSet: true}
+}
+
+func (v NullableAppPortalAccessOut) MarshalJSON() ([]byte, error) {
+	return json.Marshal(v.value)
+}
+
+func (v *NullableAppPortalAccessOut) UnmarshalJSON(src []byte) error {
+	v.isSet = true
+	return json.Unmarshal(src, &v.value)
+}
+
+
diff --git a/go/internal/openapi/model_dashboard_access_in.go b/go/internal/openapi/model_dashboard_access_in.go
new file mode 100644
index 000000000..248763dd9
--- /dev/null
+++ b/go/internal/openapi/model_dashboard_access_in.go
@@ -0,0 +1,116 @@
+/*
+ * Svix API
+ *
+ * Welcome to the Svix API documentation!  Useful links: [Homepage](https://www.svix.com) | [Support email](mailto:support+docs@svix.com) | [Blog](https://www.svix.com/blog/) | [Slack Community](https://www.svix.com/slack/)  # Introduction  This is the reference documentation and schemas for the [Svix webhook service](https://www.svix.com) API. For tutorials and other documentation please refer to [the documentation](https://docs.svix.com).  ## Main concepts  In Svix you have four important entities you will be interacting with:  - `messages`: these are the webhooks being sent. They can have contents and a few other properties. - `application`: this is where `messages` are sent to. Usually you want to create one application for each user on your platform. - `endpoint`: endpoints are the URLs messages will be sent to. Each application can have multiple `endpoints` and each message sent to that application will be sent to all of them (unless they are not subscribed to the sent event type). - `event-type`: event types are identifiers denoting the type of the message being sent. Event types are primarily used to decide which events are sent to which endpoint.   ## Authentication  Get your authentication token (`AUTH_TOKEN`) from the [Svix dashboard](https://dashboard.svix.com) and use it as part of the `Authorization` header as such: `Authorization: Bearer ${AUTH_TOKEN}`.  <SecurityDefinitions />   ## Code samples  The code samples assume you already have the respective libraries installed and you know how to use them. For the latest information on how to do that, please refer to [the documentation](https://docs.svix.com/).   ## Idempotency  Svix supports [idempotency](https://en.wikipedia.org/wiki/Idempotence) for safely retrying requests without accidentally performing the same operation twice. This is useful when an API call is disrupted in transit and you do not receive a response.  To perform an idempotent request, pass the idempotency key in the `Idempotency-Key` header to the request. The idempotency key should be a unique value generated by the client. You can create the key in however way you like, though we suggest using UUID v4, or any other string with enough entropy to avoid collisions.  Svix's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key for any successful request. Subsequent requests with the same key return the same result.  Please note that idempotency is only supported for `POST` requests.   ## Cross-Origin Resource Sharing  This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with [W3C spec](https://www.w3.org/TR/cors/). And that allows cross-domain communication from the browser. All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. 
+ *
+ * API version: 1.4
+ */
+
+// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+
+package openapi
+
+import (
+	"encoding/json"
+)
+
+// DashboardAccessIn struct for DashboardAccessIn
+type DashboardAccessIn struct {
+	FeatureFlags []string `json:"featureFlags,omitempty"`
+}
+
+// NewDashboardAccessIn instantiates a new DashboardAccessIn object
+// This constructor will assign default values to properties that have it defined,
+// and makes sure properties required by API are set, but the set of arguments
+// will change when the set of required properties is changed
+func NewDashboardAccessIn() *DashboardAccessIn {
+	this := DashboardAccessIn{}
+	return &this
+}
+
+// NewDashboardAccessInWithDefaults instantiates a new DashboardAccessIn object
+// This constructor will only assign default values to properties that have it defined,
+// but it doesn't guarantee that properties required by API are set
+func NewDashboardAccessInWithDefaults() *DashboardAccessIn {
+	this := DashboardAccessIn{}
+	return &this
+}
+
+// GetFeatureFlags returns the FeatureFlags field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *DashboardAccessIn) GetFeatureFlags() []string {
+	if o == nil  {
+		var ret []string
+		return ret
+	}
+	return o.FeatureFlags
+}
+
+// GetFeatureFlagsOk returns a tuple with the FeatureFlags field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *DashboardAccessIn) GetFeatureFlagsOk() (*[]string, bool) {
+	if o == nil || o.FeatureFlags == nil {
+		return nil, false
+	}
+	return &o.FeatureFlags, true
+}
+
+// HasFeatureFlags returns a boolean if a field has been set.
+func (o *DashboardAccessIn) HasFeatureFlags() bool {
+	if o != nil && o.FeatureFlags != nil {
+		return true
+	}
+
+	return false
+}
+
+// SetFeatureFlags gets a reference to the given []string and assigns it to the FeatureFlags field.
+func (o *DashboardAccessIn) SetFeatureFlags(v []string) {
+	o.FeatureFlags = v
+}
+
+func (o DashboardAccessIn) MarshalJSON() ([]byte, error) {
+	toSerialize := map[string]interface{}{}
+	if o.FeatureFlags != nil {
+		toSerialize["featureFlags"] = o.FeatureFlags
+	}
+	return json.Marshal(toSerialize)
+}
+
+type NullableDashboardAccessIn struct {
+	value *DashboardAccessIn
+	isSet bool
+}
+
+func (v NullableDashboardAccessIn) Get() *DashboardAccessIn {
+	return v.value
+}
+
+func (v *NullableDashboardAccessIn) Set(val *DashboardAccessIn) {
+	v.value = val
+	v.isSet = true
+}
+
+func (v NullableDashboardAccessIn) IsSet() bool {
+	return v.isSet
+}
+
+func (v *NullableDashboardAccessIn) Unset() {
+	v.value = nil
+	v.isSet = false
+}
+
+func NewNullableDashboardAccessIn(val *DashboardAccessIn) *NullableDashboardAccessIn {
+	return &NullableDashboardAccessIn{value: val, isSet: true}
+}
+
+func (v NullableDashboardAccessIn) MarshalJSON() ([]byte, error) {
+	return json.Marshal(v.value)
+}
+
+func (v *NullableDashboardAccessIn) UnmarshalJSON(src []byte) error {
+	v.isSet = true
+	return json.Unmarshal(src, &v.value)
+}
+
+
diff --git a/go/internal/openapi/model_event_type_in.go b/go/internal/openapi/model_event_type_in.go
index f7e1c0ad7..7f5cae8d4 100644
--- a/go/internal/openapi/model_event_type_in.go
+++ b/go/internal/openapi/model_event_type_in.go
@@ -18,6 +18,7 @@ import (
 type EventTypeIn struct {
 	Archived *bool `json:"archived,omitempty"`
 	Description string `json:"description"`
+	FeatureFlag NullableString `json:"featureFlag,omitempty"`
 	Name string `json:"name"`
 	// The schema for the event type for a specific version as a JSON schema.
 	Schemas map[string]map[string]interface{} `json:"schemas,omitempty"`
@@ -102,6 +103,48 @@ func (o *EventTypeIn) SetDescription(v string) {
 	o.Description = v
 }
 
+// GetFeatureFlag returns the FeatureFlag field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *EventTypeIn) GetFeatureFlag() string {
+	if o == nil || o.FeatureFlag.Get() == nil {
+		var ret string
+		return ret
+	}
+	return *o.FeatureFlag.Get()
+}
+
+// GetFeatureFlagOk returns a tuple with the FeatureFlag field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *EventTypeIn) GetFeatureFlagOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return o.FeatureFlag.Get(), o.FeatureFlag.IsSet()
+}
+
+// HasFeatureFlag returns a boolean if a field has been set.
+func (o *EventTypeIn) HasFeatureFlag() bool {
+	if o != nil && o.FeatureFlag.IsSet() {
+		return true
+	}
+
+	return false
+}
+
+// SetFeatureFlag gets a reference to the given NullableString and assigns it to the FeatureFlag field.
+func (o *EventTypeIn) SetFeatureFlag(v string) {
+	o.FeatureFlag.Set(&v)
+}
+// SetFeatureFlagNil sets the value for FeatureFlag to be an explicit nil
+func (o *EventTypeIn) SetFeatureFlagNil() {
+	o.FeatureFlag.Set(nil)
+}
+
+// UnsetFeatureFlag ensures that no value is present for FeatureFlag, not even an explicit nil
+func (o *EventTypeIn) UnsetFeatureFlag() {
+	o.FeatureFlag.Unset()
+}
+
 // GetName returns the Name field value
 func (o *EventTypeIn) GetName() string {
 	if o == nil {
@@ -167,6 +210,9 @@ func (o EventTypeIn) MarshalJSON() ([]byte, error) {
 	if true {
 		toSerialize["description"] = o.Description
 	}
+	if o.FeatureFlag.IsSet() {
+		toSerialize["featureFlag"] = o.FeatureFlag.Get()
+	}
 	if true {
 		toSerialize["name"] = o.Name
 	}
diff --git a/go/internal/openapi/model_event_type_out.go b/go/internal/openapi/model_event_type_out.go
index 678d8dd28..cef809f18 100644
--- a/go/internal/openapi/model_event_type_out.go
+++ b/go/internal/openapi/model_event_type_out.go
@@ -20,6 +20,7 @@ type EventTypeOut struct {
 	Archived *bool `json:"archived,omitempty"`
 	CreatedAt time.Time `json:"createdAt"`
 	Description string `json:"description"`
+	FeatureFlag NullableString `json:"featureFlag,omitempty"`
 	Name string `json:"name"`
 	// The schema for the event type for a specific version as a JSON schema.
 	Schemas map[string]map[string]interface{} `json:"schemas,omitempty"`
@@ -131,6 +132,48 @@ func (o *EventTypeOut) SetDescription(v string) {
 	o.Description = v
 }
 
+// GetFeatureFlag returns the FeatureFlag field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *EventTypeOut) GetFeatureFlag() string {
+	if o == nil || o.FeatureFlag.Get() == nil {
+		var ret string
+		return ret
+	}
+	return *o.FeatureFlag.Get()
+}
+
+// GetFeatureFlagOk returns a tuple with the FeatureFlag field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *EventTypeOut) GetFeatureFlagOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return o.FeatureFlag.Get(), o.FeatureFlag.IsSet()
+}
+
+// HasFeatureFlag returns a boolean if a field has been set.
+func (o *EventTypeOut) HasFeatureFlag() bool {
+	if o != nil && o.FeatureFlag.IsSet() {
+		return true
+	}
+
+	return false
+}
+
+// SetFeatureFlag gets a reference to the given NullableString and assigns it to the FeatureFlag field.
+func (o *EventTypeOut) SetFeatureFlag(v string) {
+	o.FeatureFlag.Set(&v)
+}
+// SetFeatureFlagNil sets the value for FeatureFlag to be an explicit nil
+func (o *EventTypeOut) SetFeatureFlagNil() {
+	o.FeatureFlag.Set(nil)
+}
+
+// UnsetFeatureFlag ensures that no value is present for FeatureFlag, not even an explicit nil
+func (o *EventTypeOut) UnsetFeatureFlag() {
+	o.FeatureFlag.Unset()
+}
+
 // GetName returns the Name field value
 func (o *EventTypeOut) GetName() string {
 	if o == nil {
@@ -223,6 +266,9 @@ func (o EventTypeOut) MarshalJSON() ([]byte, error) {
 	if true {
 		toSerialize["description"] = o.Description
 	}
+	if o.FeatureFlag.IsSet() {
+		toSerialize["featureFlag"] = o.FeatureFlag.Get()
+	}
 	if true {
 		toSerialize["name"] = o.Name
 	}
diff --git a/go/internal/openapi/model_event_type_update.go b/go/internal/openapi/model_event_type_update.go
index 901e0e4c1..30bb129f1 100644
--- a/go/internal/openapi/model_event_type_update.go
+++ b/go/internal/openapi/model_event_type_update.go
@@ -18,6 +18,7 @@ import (
 type EventTypeUpdate struct {
 	Archived *bool `json:"archived,omitempty"`
 	Description string `json:"description"`
+	FeatureFlag NullableString `json:"featureFlag,omitempty"`
 	// The schema for the event type for a specific version as a JSON schema.
 	Schemas map[string]map[string]interface{} `json:"schemas,omitempty"`
 }
@@ -100,6 +101,48 @@ func (o *EventTypeUpdate) SetDescription(v string) {
 	o.Description = v
 }
 
+// GetFeatureFlag returns the FeatureFlag field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *EventTypeUpdate) GetFeatureFlag() string {
+	if o == nil || o.FeatureFlag.Get() == nil {
+		var ret string
+		return ret
+	}
+	return *o.FeatureFlag.Get()
+}
+
+// GetFeatureFlagOk returns a tuple with the FeatureFlag field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *EventTypeUpdate) GetFeatureFlagOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return o.FeatureFlag.Get(), o.FeatureFlag.IsSet()
+}
+
+// HasFeatureFlag returns a boolean if a field has been set.
+func (o *EventTypeUpdate) HasFeatureFlag() bool {
+	if o != nil && o.FeatureFlag.IsSet() {
+		return true
+	}
+
+	return false
+}
+
+// SetFeatureFlag gets a reference to the given NullableString and assigns it to the FeatureFlag field.
+func (o *EventTypeUpdate) SetFeatureFlag(v string) {
+	o.FeatureFlag.Set(&v)
+}
+// SetFeatureFlagNil sets the value for FeatureFlag to be an explicit nil
+func (o *EventTypeUpdate) SetFeatureFlagNil() {
+	o.FeatureFlag.Set(nil)
+}
+
+// UnsetFeatureFlag ensures that no value is present for FeatureFlag, not even an explicit nil
+func (o *EventTypeUpdate) UnsetFeatureFlag() {
+	o.FeatureFlag.Unset()
+}
+
 // GetSchemas returns the Schemas field value if set, zero value otherwise (both if not set or set to explicit null).
 func (o *EventTypeUpdate) GetSchemas() map[string]map[string]interface{} {
 	if o == nil  {
@@ -141,6 +184,9 @@ func (o EventTypeUpdate) MarshalJSON() ([]byte, error) {
 	if true {
 		toSerialize["description"] = o.Description
 	}
+	if o.FeatureFlag.IsSet() {
+		toSerialize["featureFlag"] = o.FeatureFlag.Get()
+	}
 	if o.Schemas != nil {
 		toSerialize["schemas"] = o.Schemas
 	}
diff --git a/go/internal/openapi/model_message_attempt_endpoint_out.go b/go/internal/openapi/model_message_attempt_endpoint_out.go
index cf39487a6..e3e7dcbf3 100644
--- a/go/internal/openapi/model_message_attempt_endpoint_out.go
+++ b/go/internal/openapi/model_message_attempt_endpoint_out.go
@@ -25,13 +25,14 @@ type MessageAttemptEndpointOut struct {
 	Status MessageStatus `json:"status"`
 	Timestamp time.Time `json:"timestamp"`
 	TriggerType MessageAttemptTriggerType `json:"triggerType"`
+	Url string `json:"url"`
 }
 
 // NewMessageAttemptEndpointOut instantiates a new MessageAttemptEndpointOut object
 // This constructor will assign default values to properties that have it defined,
 // and makes sure properties required by API are set, but the set of arguments
 // will change when the set of required properties is changed
-func NewMessageAttemptEndpointOut(endpointId string, id string, msgId string, response string, responseStatusCode int32, status MessageStatus, timestamp time.Time, triggerType MessageAttemptTriggerType) *MessageAttemptEndpointOut {
+func NewMessageAttemptEndpointOut(endpointId string, id string, msgId string, response string, responseStatusCode int32, status MessageStatus, timestamp time.Time, triggerType MessageAttemptTriggerType, url string) *MessageAttemptEndpointOut {
 	this := MessageAttemptEndpointOut{}
 	this.EndpointId = endpointId
 	this.Id = id
@@ -41,6 +42,7 @@ func NewMessageAttemptEndpointOut(endpointId string, id string, msgId string, re
 	this.Status = status
 	this.Timestamp = timestamp
 	this.TriggerType = triggerType
+	this.Url = url
 	return &this
 }
 
@@ -244,6 +246,30 @@ func (o *MessageAttemptEndpointOut) SetTriggerType(v MessageAttemptTriggerType)
 	o.TriggerType = v
 }
 
+// GetUrl returns the Url field value
+func (o *MessageAttemptEndpointOut) GetUrl() string {
+	if o == nil {
+		var ret string
+		return ret
+	}
+
+	return o.Url
+}
+
+// GetUrlOk returns a tuple with the Url field value
+// and a boolean to check if the value has been set.
+func (o *MessageAttemptEndpointOut) GetUrlOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return &o.Url, true
+}
+
+// SetUrl sets field value
+func (o *MessageAttemptEndpointOut) SetUrl(v string) {
+	o.Url = v
+}
+
 func (o MessageAttemptEndpointOut) MarshalJSON() ([]byte, error) {
 	toSerialize := map[string]interface{}{}
 	if true {
@@ -270,6 +296,9 @@ func (o MessageAttemptEndpointOut) MarshalJSON() ([]byte, error) {
 	if true {
 		toSerialize["triggerType"] = o.TriggerType
 	}
+	if true {
+		toSerialize["url"] = o.Url
+	}
 	return json.Marshal(toSerialize)
 }
 
diff --git a/go/internal/openapi/model_message_attempt_out.go b/go/internal/openapi/model_message_attempt_out.go
index 843493054..dce69fb22 100644
--- a/go/internal/openapi/model_message_attempt_out.go
+++ b/go/internal/openapi/model_message_attempt_out.go
@@ -25,13 +25,14 @@ type MessageAttemptOut struct {
 	Status MessageStatus `json:"status"`
 	Timestamp time.Time `json:"timestamp"`
 	TriggerType MessageAttemptTriggerType `json:"triggerType"`
+	Url string `json:"url"`
 }
 
 // NewMessageAttemptOut instantiates a new MessageAttemptOut object
 // This constructor will assign default values to properties that have it defined,
 // and makes sure properties required by API are set, but the set of arguments
 // will change when the set of required properties is changed
-func NewMessageAttemptOut(endpointId string, id string, msgId string, response string, responseStatusCode int32, status MessageStatus, timestamp time.Time, triggerType MessageAttemptTriggerType) *MessageAttemptOut {
+func NewMessageAttemptOut(endpointId string, id string, msgId string, response string, responseStatusCode int32, status MessageStatus, timestamp time.Time, triggerType MessageAttemptTriggerType, url string) *MessageAttemptOut {
 	this := MessageAttemptOut{}
 	this.EndpointId = endpointId
 	this.Id = id
@@ -41,6 +42,7 @@ func NewMessageAttemptOut(endpointId string, id string, msgId string, response s
 	this.Status = status
 	this.Timestamp = timestamp
 	this.TriggerType = triggerType
+	this.Url = url
 	return &this
 }
 
@@ -244,6 +246,30 @@ func (o *MessageAttemptOut) SetTriggerType(v MessageAttemptTriggerType) {
 	o.TriggerType = v
 }
 
+// GetUrl returns the Url field value
+func (o *MessageAttemptOut) GetUrl() string {
+	if o == nil {
+		var ret string
+		return ret
+	}
+
+	return o.Url
+}
+
+// GetUrlOk returns a tuple with the Url field value
+// and a boolean to check if the value has been set.
+func (o *MessageAttemptOut) GetUrlOk() (*string, bool) {
+	if o == nil  {
+		return nil, false
+	}
+	return &o.Url, true
+}
+
+// SetUrl sets field value
+func (o *MessageAttemptOut) SetUrl(v string) {
+	o.Url = v
+}
+
 func (o MessageAttemptOut) MarshalJSON() ([]byte, error) {
 	toSerialize := map[string]interface{}{}
 	if true {
@@ -270,6 +296,9 @@ func (o MessageAttemptOut) MarshalJSON() ([]byte, error) {
 	if true {
 		toSerialize["triggerType"] = o.TriggerType
 	}
+	if true {
+		toSerialize["url"] = o.Url
+	}
 	return json.Marshal(toSerialize)
 }
 
diff --git a/go/internal/version/version.go b/go/internal/version/version.go
index c2ba15d17..4c0334e2f 100644
--- a/go/internal/version/version.go
+++ b/go/internal/version/version.go
@@ -1,3 +1,3 @@
 package version
 
-const Version = "0.72.0"
+const Version = "0.75.0"
diff --git a/java/README.md b/java/README.md
index eb7a09566..c72a57451 100644
--- a/java/README.md
+++ b/java/README.md
@@ -51,7 +51,7 @@ Add this dependency to your project's POM:
 <dependency>
   <groupId>com.svix</groupId>
   <artifactId>svix</artifactId>
-  <version>0.72.0</version>
+  <version>0.75.0</version>
   <scope>compile</scope>
 </dependency>
 ```
@@ -61,7 +61,7 @@ Add this dependency to your project's POM:
 Add this dependency to your project's build file:
 
 ```groovy
-implementation "com.svix:svix:0.72.0"
+implementation "com.svix:svix:0.75.0"
 ```
 
 # Development
diff --git a/java/gradle.properties b/java/gradle.properties
index c8a612ff6..84394a7b7 100644
--- a/java/gradle.properties
+++ b/java/gradle.properties
@@ -1,5 +1,5 @@
 GROUP=com.svix
-VERSION_NAME=0.72.0
+VERSION_NAME=0.75.0
 
 POM_URL=https://github.com/svix/svix-webhooks
 POM_SCM_URL=git@github.com:svix/svix-webhooks.git
diff --git a/java/lib/src/main/java/com/svix/Authentication.java b/java/lib/src/main/java/com/svix/Authentication.java
index 929f0839e..7413e9e2e 100644
--- a/java/lib/src/main/java/com/svix/Authentication.java
+++ b/java/lib/src/main/java/com/svix/Authentication.java
@@ -2,6 +2,8 @@
 
 import com.svix.exceptions.ApiException;
 import com.svix.internal.api.AuthenticationApi;
+import com.svix.models.AppPortalAccessIn;
+import com.svix.models.AppPortalAccessOut;
 import com.svix.models.DashboardAccessOut;
 
 public final class Authentication {
@@ -11,6 +13,18 @@ public final class Authentication {
 		api = new AuthenticationApi();
 	}
 
+	public AppPortalAccessOut appPortalAccess(final String appId, final AppPortalAccessIn appPortalAccessIn) throws ApiException {
+		return this.appPortalAccess(appId, appPortalAccessIn, new PostOptions());
+	}
+
+	public AppPortalAccessOut appPortalAccess(final String appId, final AppPortalAccessIn appPortalAccessIn, final PostOptions options) throws ApiException {
+		try {
+			return api.getAppPortalAccessApiV1AuthAppPortalAccessAppIdPost(appId, appPortalAccessIn, options.getIdempotencyKey());
+		} catch (com.svix.internal.ApiException e) {
+			throw Utils.wrapInternalApiException(e);
+		}
+	}
+
 	public DashboardAccessOut dashboardAccess(final String appId) throws ApiException {
 		return this.dashboardAccess(appId, new PostOptions());
 	}
diff --git a/java/lib/src/main/java/com/svix/Svix.java b/java/lib/src/main/java/com/svix/Svix.java
index 9d81f9788..db85df906 100644
--- a/java/lib/src/main/java/com/svix/Svix.java
+++ b/java/lib/src/main/java/com/svix/Svix.java
@@ -5,7 +5,7 @@
 import com.svix.internal.auth.HttpBearerAuth;
 
 public final class Svix {
-	public static final String VERSION = "0.72.0";
+	public static final String VERSION = "0.75.0";
 	private final Application application;
 	private final Authentication authentication;
 	private final Endpoint endpoint;
diff --git a/javascript/package.json b/javascript/package.json
index 4b513fc22..7badb77a1 100644
--- a/javascript/package.json
+++ b/javascript/package.json
@@ -1,6 +1,6 @@
 {
   "name": "svix",
-  "version": "0.72.0",
+  "version": "0.75.0",
   "description": "Svix API client",
   "author": "svix",
   "repository": "https://github.com/svix/svix-libs",
diff --git a/javascript/src/index.ts b/javascript/src/index.ts
index 77fbffd88..31c1fc226 100644
--- a/javascript/src/index.ts
+++ b/javascript/src/index.ts
@@ -48,6 +48,8 @@ import {
   Middleware,
   RequestContext,
   ResponseContext,
+  AppPortalAccessOut,
+  AppPortalAccessIn,
 } from "./openapi/index";
 export * from "./openapi/models/all";
 export * from "./openapi/apis/exception";
@@ -56,7 +58,7 @@ import * as base64 from "@stablelib/base64";
 import * as sha256 from "fast-sha256";
 
 const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5 minutes
-const VERSION = "0.72.0";
+const VERSION = "0.75.0";
 
 class UserAgentMiddleware implements Middleware {
   public pre(context: RequestContext): Promise<RequestContext> {
@@ -129,6 +131,18 @@ class Authentication {
     this.api = new AuthenticationApi(config);
   }
 
+  public appPortalAccess(
+    appId: string,
+    appPortalAccessIn: AppPortalAccessIn,
+    options?: PostOptions
+  ): Promise<AppPortalAccessOut> {
+    return this.api.getAppPortalAccessApiV1AuthAppPortalAccessAppIdPost({
+      appId,
+      appPortalAccessIn,
+      ...options,
+    });
+  }
+
   public dashboardAccess(
     appId: string,
     options?: PostOptions
diff --git a/javascript/yarn.lock b/javascript/yarn.lock
index 181c6a139..5c3c40197 100644
--- a/javascript/yarn.lock
+++ b/javascript/yarn.lock
@@ -2307,11 +2307,9 @@ json-stable-stringify-without-jsonify@^1.0.1:
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
 json5@2.x, json5@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
-  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
-  dependencies:
-    minimist "^1.2.5"
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
 
 kleur@^3.0.3:
   version "3.0.3"
@@ -2427,11 +2425,6 @@ minimatch@^3.0.4:
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@^1.2.5:
-  version "1.2.6"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
-  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
-
 mkdirp@1.x:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
diff --git a/kotlin/README.md b/kotlin/README.md
index 739538c54..b6cfb928a 100644
--- a/kotlin/README.md
+++ b/kotlin/README.md
@@ -51,7 +51,7 @@ Add this dependency to your project's POM:
 <dependency>
   <groupId>com.svix.kotlin</groupId>
   <artifactId>svix-kotlin</artifactId>
-  <version>0.72.0</version>
+  <version>0.75.0</version>
   <scope>compile</scope>
 </dependency>
 ```
@@ -61,7 +61,7 @@ Add this dependency to your project's POM:
 Add this dependency to your project's build file:
 
 ```groovy
-implementation "com.svix.kotlin:svix-kotlin:0.72.0"
+implementation "com.svix.kotlin:svix-kotlin:0.75.0"
 ```
 
 # Development
diff --git a/kotlin/gradle.properties b/kotlin/gradle.properties
index 81e5b2445..36968becb 100644
--- a/kotlin/gradle.properties
+++ b/kotlin/gradle.properties
@@ -1,5 +1,5 @@
 GROUP=com.svix.kotlin
-VERSION_NAME=0.72.0
+VERSION_NAME=0.75.0
 
 POM_URL=https://github.com/svix/svix-webhooks
 POM_SCM_URL=git@github.com:svix/svix-webhooks.git
diff --git a/kotlin/lib/src/main/kotlin/Authentication.kt b/kotlin/lib/src/main/kotlin/Authentication.kt
index 1cd690602..fefad7833 100644
--- a/kotlin/lib/src/main/kotlin/Authentication.kt
+++ b/kotlin/lib/src/main/kotlin/Authentication.kt
@@ -2,6 +2,8 @@ package com.svix.kotlin
 
 import com.svix.kotlin.exceptions.ApiException
 import com.svix.kotlin.internal.apis.AuthenticationApi
+import com.svix.kotlin.models.AppPortalAccessIn
+import com.svix.kotlin.models.AppPortalAccessOut
 import com.svix.kotlin.models.DashboardAccessOut
 
 class Authentication internal constructor(token: String, options: SvixOptions) {
@@ -14,6 +16,18 @@ class Authentication internal constructor(token: String, options: SvixOptions) {
         options.numRetries?.let { api.numRetries = it }
     }
 
+    suspend fun appPortalAccess(
+        appId: String,
+        appPortalAccessIn: AppPortalAccessIn,
+        options: PostOptions = PostOptions()
+    ): AppPortalAccessOut {
+        try {
+            return api.getAppPortalAccessApiV1AuthAppPortalAccessAppIdPost(appId, appPortalAccessIn, options.idempotencyKey)
+        } catch (e: Exception) {
+            throw ApiException.wrap(e)
+        }
+    }
+
     suspend fun dashboardAccess(appId: String, options: PostOptions = PostOptions()): DashboardAccessOut {
         try {
             return api.getDashboardAccessApiV1AuthDashboardAccessAppIdPost(appId, options.idempotencyKey)
diff --git a/kotlin/lib/src/main/kotlin/SvixOptions.kt b/kotlin/lib/src/main/kotlin/SvixOptions.kt
index 3d6f698cf..7cb3edd63 100644
--- a/kotlin/lib/src/main/kotlin/SvixOptions.kt
+++ b/kotlin/lib/src/main/kotlin/SvixOptions.kt
@@ -6,7 +6,7 @@ data class SvixOptions(
     val numRetries: Int? = null
 ) {
 
-    private val version = "0.72.0"
+    private val version = "0.75.0"
 
     var serverUrl: String
         get() = this.wantedServerUrl ?: DEFAULT_URL
diff --git a/openapi.json b/openapi.json
index d17aa9013..2c0a09598 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1,6 +1,47 @@
 {
     "components": {
         "schemas": {
+            "AppPortalAccessIn": {
+                "properties": {
+                    "featureFlags": {
+                        "items": {
+                            "example": "cool-new-feature",
+                            "maxLength": 256,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
+                            "type": "string"
+                        },
+                        "nullable": true,
+                        "title": "Featureflags",
+                        "type": "array",
+                        "uniqueItems": true
+                    }
+                },
+                "title": "AppPortalAccessIn",
+                "type": "object"
+            },
+            "AppPortalAccessOut": {
+                "properties": {
+                    "token": {
+                        "example": "appsk_kV3ts5tKPNJN4Dl25cMTfUNdmabxbX0O",
+                        "title": "Token",
+                        "type": "string"
+                    },
+                    "url": {
+                        "example": "https://app.svix.com/login#key=eyJhcHBJZCI6ICJhcHBfMXRSdFl",
+                        "format": "uri",
+                        "maxLength": 65536,
+                        "minLength": 1,
+                        "title": "Url",
+                        "type": "string"
+                    }
+                },
+                "required": [
+                    "url",
+                    "token"
+                ],
+                "title": "AppPortalAccessOut",
+                "type": "object"
+            },
             "ApplicationIn": {
                 "properties": {
                     "metadata": {
@@ -630,6 +671,9 @@
                             "user.deleted"
                         ],
                         "items": {
+                            "example": "user.signup",
+                            "maxLength": 256,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
                             "type": "string"
                         },
                         "minItems": 1,
@@ -814,6 +858,9 @@
                             "user.deleted"
                         ],
                         "items": {
+                            "example": "user.signup",
+                            "maxLength": 256,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
                             "type": "string"
                         },
                         "minItems": 1,
@@ -1080,6 +1127,9 @@
                             "user.deleted"
                         ],
                         "items": {
+                            "example": "user.signup",
+                            "maxLength": 256,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
                             "type": "string"
                         },
                         "minItems": 1,
@@ -1371,6 +1421,14 @@
                         "title": "Description",
                         "type": "string"
                     },
+                    "featureFlag": {
+                        "example": "cool-new-feature",
+                        "maxLength": 256,
+                        "nullable": true,
+                        "pattern": "^[a-zA-Z0-9\\-_.]+$",
+                        "title": "Featureflag",
+                        "type": "string"
+                    },
                     "name": {
                         "example": "user.signup",
                         "maxLength": 256,
@@ -1453,6 +1511,14 @@
                         "title": "Description",
                         "type": "string"
                     },
+                    "featureFlag": {
+                        "example": "cool-new-feature",
+                        "maxLength": 256,
+                        "nullable": true,
+                        "pattern": "^[a-zA-Z0-9\\-_.]+$",
+                        "title": "Featureflag",
+                        "type": "string"
+                    },
                     "name": {
                         "example": "user.signup",
                         "maxLength": 256,
@@ -1570,6 +1636,14 @@
                         "title": "Description",
                         "type": "string"
                     },
+                    "featureFlag": {
+                        "example": "cool-new-feature",
+                        "maxLength": 256,
+                        "nullable": true,
+                        "pattern": "^[a-zA-Z0-9\\-_.]+$",
+                        "title": "Featureflag",
+                        "type": "string"
+                    },
                     "schemas": {
                         "additionalProperties": {
                             "example": {
@@ -2078,12 +2152,21 @@
                     },
                     "triggerType": {
                         "$ref": "#/components/schemas/MessageAttemptTriggerType"
+                    },
+                    "url": {
+                        "example": "https://example.com/webhook/",
+                        "format": "uri",
+                        "maxLength": 65536,
+                        "minLength": 1,
+                        "title": "Url",
+                        "type": "string"
                     }
                 },
                 "required": [
                     "id",
                     "msgId",
                     "endpointId",
+                    "url",
                     "response",
                     "responseStatusCode",
                     "timestamp",
@@ -2344,12 +2427,21 @@
                     },
                     "triggerType": {
                         "$ref": "#/components/schemas/MessageAttemptTriggerType"
+                    },
+                    "url": {
+                        "example": "https://example.com/webhook/",
+                        "format": "uri",
+                        "maxLength": 65536,
+                        "minLength": 1,
+                        "title": "Url",
+                        "type": "string"
                     }
                 },
                 "required": [
                     "id",
                     "msgId",
                     "endpointId",
+                    "url",
                     "response",
                     "responseStatusCode",
                     "timestamp",
@@ -2416,6 +2508,9 @@
                             "user.deleted"
                         ],
                         "items": {
+                            "example": "user.signup",
+                            "maxLength": 256,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
                             "type": "string"
                         },
                         "minItems": 1,
@@ -3328,7 +3423,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let application_out = svix.application().create(ApplicationIn {\n    uid: \"unique-app-identifier\".to_string(),\n    name: \"My first application\".to_string(),\n    rate_limit: 1000,\n    metadata: None\n}).await?;\n// Or \nlet application_out = svix.application().get_or_create(ApplicationIn {\n    uid: \"unique-app-identifier\".to_string(),\n    name: \"My first application\".to_string(),\n    rate_limit: 1000,\n    metadata: None\n}).await?;"
+                        "source": "let application_out = svix.application().create(ApplicationIn {\n    uid: Some(\"unique-app-identifier\".to_string()),\n    name: \"My first application\".to_string(),\n    rate_limit: Some(1000),\n    metadata: None\n}, None).await?;\n// Or \nlet application_out = svix.application().get_or_create(ApplicationIn {\n    uid: Some(\"unique-app-identifier\".to_string()),\n    name: \"My first application\".to_string(),\n    rate_limit: Some(1000),\n    metadata: None\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -4018,7 +4113,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let application_out = svix.application().update(\"app_id\", ApplicationIn {\n    uid: \"unique-app-identifier\".to_string(),\n    name: \"My first application\".to_string(),\n    rate_limit: 1000,\n    metadata: None\n}).await?;"
+                        "source": "let application_out = svix.application().update(\"app_id\", ApplicationIn {\n    uid: Some(\"unique-app-identifier\".to_string()),\n    name: \"My first application\".to_string(),\n    rate_limit: Some(1000),\n    metadata: None\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -4981,7 +5076,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let endpoint_out = svix.endpoint().create(\"app_id\", EndpointIn {\n    uid: \"unique-endpoint-identifier\".to_string(),\n    url: \"https://example.com/webhook/\".to_string(),\n    version: 1,\n    description: \"An example endpoint name\".to_string(),\n    filter_types: vec![\"user.signup\".to_string(), \"user.deleted\".to_string()],\n    channels: vec![\"project_123\".to_string(), \"group_2\".to_string()],\n    disabled: False,\n    rate_limit: 1000,\n    metadata: None,\n    secret: \"whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD\".to_string()\n}).await?;"
+                        "source": "let endpoint_out = svix.endpoint().create(\"app_id\", EndpointIn {\n    uid: Some(\"unique-endpoint-identifier\".to_string()),\n    url: \"https://example.com/webhook/\".to_string(),\n    version: 1,\n    description: Some(\"An example endpoint name\".to_string()),\n    filter_types: Some(vec![\"user.signup\".to_string(), \"user.deleted\".to_string()]),\n    channels: Some(vec![\"project_123\".to_string(), \"group_2\".to_string()]),\n    disabled: Some(False),\n    rate_limit: Some(1000),\n    metadata: None,\n    secret: Some(\"whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD\".to_string())\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -5563,7 +5658,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let endpoint_out = svix.endpoint().update(\"app_id\", \"endpoint_id\", EndpointUpdate {\n    uid: \"unique-endpoint-identifier\".to_string(),\n    url: \"https://example.com/webhook/\".to_string(),\n    version: 1,\n    description: \"An example endpoint name\".to_string(),\n    filter_types: vec![\"user.signup\".to_string(), \"user.deleted\".to_string()],\n    channels: vec![\"project_123\".to_string(), \"group_2\".to_string()],\n    disabled: False,\n    rate_limit: 1000,\n    metadata: None\n}).await?;"
+                        "source": "let endpoint_out = svix.endpoint().update(\"app_id\", \"endpoint_id\", EndpointUpdate {\n    uid: Some(\"unique-endpoint-identifier\".to_string()),\n    url: \"https://example.com/webhook/\".to_string(),\n    version: 1,\n    description: Some(\"An example endpoint name\".to_string()),\n    filter_types: Some(vec![\"user.signup\".to_string(), \"user.deleted\".to_string()]),\n    channels: Some(vec![\"project_123\".to_string(), \"group_2\".to_string()]),\n    disabled: Some(False),\n    rate_limit: Some(1000),\n    metadata: None\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -6138,7 +6233,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "svix.endpoint().update_headers(\"app_id\", \"endpoint_id\", EndpointHeadersIn {\n    headers: HashMap::from([\n        (\"X-Foobar.\"to_string(), \"Bar\".to_string()),\n        (\"X-Example.\"to_string(), \"123\".to_string())\n    ])\n}).await?;"
+                        "source": "svix.endpoint().update_headers(\"app_id\", \"endpoint_id\", EndpointHeadersIn {\n    headers: HashMap::from([\n        (\"X-Foobar.\"to_string(), \"Bar\".to_string()),\n        (\"X-Example.\"to_string(), \"123\".to_string())\n    ])\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -6541,7 +6636,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let recover_out = svix.endpoint().recover(\"app_id\", \"endpoint_id\", RecoverIn {\n    since: \"2019-08-24T14:15:22Z\".to_string(),\n    until: \"2019-08-24T14:15:22Z\".to_string()\n}).await?;"
+                        "source": "let recover_out = svix.endpoint().recover(\"app_id\", \"endpoint_id\", RecoverIn {\n    since: \"2019-08-24T14:15:22Z\".to_string(),\n    until: Some(\"2019-08-24T14:15:22Z\".to_string())\n}).await?;"
                     },
                     {
                         "label": "C#",
@@ -7082,7 +7177,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "svix.endpoint().rotate_secret(\"app_id\", \"endpoint_id\", EndpointSecretRotateIn {\n    key: \"whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD\".to_string()\n}).await?;"
+                        "source": "svix.endpoint().rotate_secret(\"app_id\", \"endpoint_id\", EndpointSecretRotateIn {\n    key: Some(\"whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD\".to_string())\n}).await?;"
                     },
                     {
                         "label": "C#",
@@ -8461,7 +8556,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let integration_out = svix.integration().create(\"app_id\", IntegrationIn {\n    name: \"Example Integration\".to_string()\n}).await?;"
+                        "source": "let integration_out = svix.integration().create(\"app_id\", IntegrationIn {\n    name: \"Example Integration\".to_string()\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -9021,7 +9116,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let integration_out = svix.integration().update(\"app_id\", \"integ_id\", IntegrationUpdate {\n    name: \"Example Integration\".to_string()\n}).await?;"
+                        "source": "let integration_out = svix.integration().update(\"app_id\", \"integ_id\", IntegrationUpdate {\n    name: \"Example Integration\".to_string()\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -9850,7 +9945,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let message_out = svix.message().create(\"app_id\", MessageIn {\n    event_type: \"user.signup\".to_string(),\n    event_id: \"evt_pNZKtWg8Azow\".to_string(),\n    channels: vec![\"project_123\".to_string(), \"group_2\".to_string()],\n    payload: json!({\n        \"username\": \"test_user\",\n        \"email\": \"test@example.com\"\n    }),\n    payload_retention_period: 90\n}).await?;"
+                        "source": "let message_out = svix.message().create(\"app_id\", MessageIn {\n    event_type: \"user.signup\".to_string(),\n    event_id: Some(\"evt_pNZKtWg8Azow\".to_string()),\n    channels: Some(vec![\"project_123\".to_string(), \"group_2\".to_string()]),\n    payload: json!({\n        \"username\": \"test_user\",\n        \"email\": \"test@example.com\"\n    }),\n    payload_retention_period: Some(90)\n}, None).await?;"
                     },
                     {
                         "label": "C#",
@@ -11493,6 +11588,193 @@
                 ]
             }
         },
+        "/api/v1/auth/app-portal-access/{app_id}/": {
+            "post": {
+                "description": "Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.",
+                "operationId": "get_app_portal_access_api_v1_auth_app_portal_access__app_id___post",
+                "parameters": [
+                    {
+                        "in": "path",
+                        "name": "app_id",
+                        "required": true,
+                        "schema": {
+                            "description": "The application's ID or UID",
+                            "example": "app_1srOrx2ZWZBpBUvZwXKQmoEYga2",
+                            "maxLength": 256,
+                            "minLength": 1,
+                            "pattern": "^[a-zA-Z0-9\\-_.]+$",
+                            "title": "App Id",
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "description": "The request's idempotency key",
+                        "in": "header",
+                        "name": "idempotency-key",
+                        "required": false,
+                        "schema": {
+                            "description": "The request's idempotency key",
+                            "nullable": true,
+                            "title": "Idempotency-Key",
+                            "type": "string"
+                        }
+                    }
+                ],
+                "requestBody": {
+                    "content": {
+                        "application/json": {
+                            "schema": {
+                                "$ref": "#/components/schemas/AppPortalAccessIn"
+                            }
+                        }
+                    },
+                    "required": true
+                },
+                "responses": {
+                    "200": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/AppPortalAccessOut"
+                                }
+                            }
+                        },
+                        "description": "Successful Response"
+                    },
+                    "401": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HttpErrorOut"
+                                }
+                            }
+                        },
+                        "description": "Unauthorized"
+                    },
+                    "403": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HttpErrorOut"
+                                }
+                            }
+                        },
+                        "description": "Forbidden"
+                    },
+                    "404": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HttpErrorOut"
+                                }
+                            }
+                        },
+                        "description": "Not Found"
+                    },
+                    "409": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HttpErrorOut"
+                                }
+                            }
+                        },
+                        "description": "Conflict"
+                    },
+                    "422": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                        "description": "Validation Error"
+                    },
+                    "429": {
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HttpErrorOut"
+                                }
+                            }
+                        },
+                        "description": "Too Many Requests"
+                    }
+                },
+                "security": [
+                    {
+                        "HTTPBearer": []
+                    }
+                ],
+                "summary": "Get Consumer App Portal Access",
+                "tags": [
+                    "Authentication"
+                ],
+                "x-codeSamples": [
+                    {
+                        "label": "JavaScript",
+                        "lang": "JavaScript",
+                        "source": "const appPortalAccessOut = await svix.authentication.appPortalAccess('app_id', {\n    featureFlags: None\n});"
+                    },
+                    {
+                        "label": "TypeScript",
+                        "lang": "JavaScript",
+                        "source": "const appPortalAccessOut = await svix.authentication.appPortalAccess('app_id', {\n    featureFlags: None\n});"
+                    },
+                    {
+                        "label": "Python",
+                        "lang": "Python",
+                        "source": "app_portal_access_out = svix.authentication.app_portal_access('app_id', AppPortalAccessIn(\n    feature_flags=None\n))"
+                    },
+                    {
+                        "label": "Python (Async)",
+                        "lang": "Python",
+                        "source": "app_portal_access_out = await svix.authentication.app_portal_access('app_id', AppPortalAccessIn(\n    feature_flags=None\n))"
+                    },
+                    {
+                        "label": "Go",
+                        "lang": "Go",
+                        "source": "appPortalAccessOut, err := svixClient.Authentication.AppPortalAccess(\"app_id\", &svix.AppPortalAccessIn{\n    FeatureFlags: None\n})"
+                    },
+                    {
+                        "label": "Kotlin",
+                        "lang": "Kotlin",
+                        "source": "val appPortalAccessOut = svix.authentication.appPortalAccess('app_id', AppPortalAccessIn()\n    .featureFlags(None)\n)"
+                    },
+                    {
+                        "label": "Java",
+                        "lang": "Java",
+                        "source": "AppPortalAccessOut appPortalAccessOut = svix.getAuthentication().appPortalAccess('app_id', new AppPortalAccessIn()\n    .featureFlags(None)\n)"
+                    },
+                    {
+                        "label": "Ruby",
+                        "lang": "Ruby",
+                        "source": "app_portal_access_out = svix.authentication.app_portal_access('app_id', Svix::AppPortalAccessIn.new({\n    \"feature_flags\": null\n}))"
+                    },
+                    {
+                        "label": "Rust",
+                        "lang": "Rust",
+                        "source": "let app_portal_access_out = svix.authentication().app_portal_access(\"app_id\", AppPortalAccessIn {\n    feature_flags: None\n}).await?;"
+                    },
+                    {
+                        "label": "C#",
+                        "lang": "C#",
+                        "source": "var appPortalAccessOut = await svix.Authentication.AppPortalAccessAsync(\"app_id\", new AppPortalAccessIn{\n    featureFlags: None\n})"
+                    },
+                    {
+                        "label": "CLI",
+                        "lang": "Shell",
+                        "source": "svix authentication app-portal-access 'app_id' '{\n    \"featureFlags\": null\n}'"
+                    },
+                    {
+                        "label": "cURL",
+                        "lang": "Shell",
+                        "source": "curl -X 'POST' \\\n  'https://api.svix.com/api/v1/auth/app-portal-access/{app_id}/' \\\n  -H 'Authorization: Bearer AUTH_TOKEN' \\\n  -H 'accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"featureFlags\": null\n}'"
+                    }
+                ]
+            }
+        },
         "/api/v1/auth/app/{app_id}/expire-all/": {
             "post": {
                 "description": "Expire all of the tokens associated with a specific Application",
@@ -11653,7 +11935,7 @@
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "svix.authentication().expire_all(\"app_id\", ApplicationTokenExpireIn {\n    expiry: 60\n}).await?;"
+                        "source": "svix.authentication().expire_all(\"app_id\", ApplicationTokenExpireIn {\n    expiry: Some(60)\n}).await?;"
                     },
                     {
                         "label": "C#",
@@ -11675,7 +11957,8 @@
         },
         "/api/v1/auth/dashboard-access/{app_id}/": {
             "post": {
-                "description": "Use this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.",
+                "deprecated": true,
+                "description": "DEPRECATED: Please use `app-portal-access` instead.\n\nUse this function to get magic links (and authentication codes) for connecting your users to the Consumer Application Portal.",
                 "operationId": "get_dashboard_access_api_v1_auth_dashboard_access__app_id___post",
                 "parameters": [
                     {
@@ -12661,62 +12944,62 @@
                     {
                         "label": "JavaScript",
                         "lang": "JavaScript",
-                        "source": "const eventTypeOut = await svix.eventType.create({\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    name: \"user.signup\"\n});"
+                        "source": "const eventTypeOut = await svix.eventType.create({\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\",\n    name: \"user.signup\"\n});"
                     },
                     {
                         "label": "TypeScript",
                         "lang": "JavaScript",
-                        "source": "const eventTypeOut = await svix.eventType.create({\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    name: \"user.signup\"\n});"
+                        "source": "const eventTypeOut = await svix.eventType.create({\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\",\n    name: \"user.signup\"\n});"
                     },
                     {
                         "label": "Python",
                         "lang": "Python",
-                        "source": "event_type_out = svix.event_type.create(EventTypeIn(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    name=\"user.signup\"\n))"
+                        "source": "event_type_out = svix.event_type.create(EventTypeIn(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    feature_flag=\"cool-new-feature\",\n    name=\"user.signup\"\n))"
                     },
                     {
                         "label": "Python (Async)",
                         "lang": "Python",
-                        "source": "event_type_out = await svix.event_type.create(EventTypeIn(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    name=\"user.signup\"\n))"
+                        "source": "event_type_out = await svix.event_type.create(EventTypeIn(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    feature_flag=\"cool-new-feature\",\n    name=\"user.signup\"\n))"
                     },
                     {
                         "label": "Go",
                         "lang": "Go",
-                        "source": "eventTypeOut, err := svixClient.EventType.Create(&svix.EventTypeIn{\n    Description: \"A user has signed up\",\n    Schemas: map[string]interface{}{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    Archived: False,\n    Name: \"user.signup\"\n})"
+                        "source": "eventTypeOut, err := svixClient.EventType.Create(&svix.EventTypeIn{\n    Description: \"A user has signed up\",\n    Schemas: map[string]interface{}{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    Archived: False,\n    FeatureFlag: \"cool-new-feature\",\n    Name: \"user.signup\"\n})"
                     },
                     {
                         "label": "Kotlin",
                         "lang": "Kotlin",
-                        "source": "val eventTypeOut = svix.eventType.create(EventTypeIn()\n    .description(\"A user has signed up\"),\n    .schemas(\"\"\"{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }\"\"\"),\n    .archived(False),\n    .name(\"user.signup\")\n)"
+                        "source": "val eventTypeOut = svix.eventType.create(EventTypeIn()\n    .description(\"A user has signed up\"),\n    .schemas(\"\"\"{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }\"\"\"),\n    .archived(False),\n    .featureFlag(\"cool-new-feature\"),\n    .name(\"user.signup\")\n)"
                     },
                     {
                         "label": "Java",
                         "lang": "Java",
-                        "source": "EventTypeOut eventTypeOut = svix.getEventType().create(new EventTypeIn()\n    .description(\"A user has signed up\"),\n    .schemas(\"{\" +\n        \"\\\"1\\\": {\" +\n                \"\\\"title\\\": \\\"Invoice Paid Event\\\",\" +\n                \"\\\"description\\\": \\\"An invoice was paid by a user\\\",\" +\n                \"\\\"type\\\": \\\"object\\\",\" +\n                \"\\\"properties\\\": {\" +\n                        \"\\\"invoiceId\\\": {\" +\n                                \"\\\"description\\\": \\\"The invoice id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"},\" +\n                        \"\\\"userId\\\": {\" +\n                                \"\\\"description\\\": \\\"The user id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"}\" +\n                \"},\" +\n                \"\\\"required\\\": [\" +\n                        \"\\\"invoiceId\\\",\" +\n                        \"\\\"userId\\\"\" +\n                \"]\" +\n        \"}\" +\n    \"}\"),\n    .archived(False),\n    .name(\"user.signup\")\n)"
+                        "source": "EventTypeOut eventTypeOut = svix.getEventType().create(new EventTypeIn()\n    .description(\"A user has signed up\"),\n    .schemas(\"{\" +\n        \"\\\"1\\\": {\" +\n                \"\\\"title\\\": \\\"Invoice Paid Event\\\",\" +\n                \"\\\"description\\\": \\\"An invoice was paid by a user\\\",\" +\n                \"\\\"type\\\": \\\"object\\\",\" +\n                \"\\\"properties\\\": {\" +\n                        \"\\\"invoiceId\\\": {\" +\n                                \"\\\"description\\\": \\\"The invoice id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"},\" +\n                        \"\\\"userId\\\": {\" +\n                                \"\\\"description\\\": \\\"The user id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"}\" +\n                \"},\" +\n                \"\\\"required\\\": [\" +\n                        \"\\\"invoiceId\\\",\" +\n                        \"\\\"userId\\\"\" +\n                \"]\" +\n        \"}\" +\n    \"}\"),\n    .archived(False),\n    .featureFlag(\"cool-new-feature\"),\n    .name(\"user.signup\")\n)"
                     },
                     {
                         "label": "Ruby",
                         "lang": "Ruby",
-                        "source": "event_type_out = svix.event_type.create(Svix::EventTypeIn.new({\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"name\": \"user.signup\"\n}))"
+                        "source": "event_type_out = svix.event_type.create(Svix::EventTypeIn.new({\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"feature_flag\": \"cool-new-feature\",\n    \"name\": \"user.signup\"\n}))"
                     },
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let event_type_out = svix.event_type().create(EventTypeIn {\n    description: \"A user has signed up\".to_string(),\n    schemas: json!({\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }),\n    archived: False,\n    name: \"user.signup\".to_string()\n}).await?;"
+                        "source": "let event_type_out = svix.event_type().create(EventTypeIn {\n    description: \"A user has signed up\".to_string(),\n    schemas: Some(json!({\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    })),\n    archived: Some(False),\n    feature_flag: Some(\"cool-new-feature\".to_string()),\n    name: \"user.signup\".to_string()\n}, None).await?;"
                     },
                     {
                         "label": "C#",
                         "lang": "C#",
-                        "source": "var eventTypeOut = await svix.EventType.CreateAsync(new EventTypeIn{\n    description: \"A user has signed up\",\n    schemas: new {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    name: \"user.signup\"\n})"
+                        "source": "var eventTypeOut = await svix.EventType.CreateAsync(new EventTypeIn{\n    description: \"A user has signed up\",\n    schemas: new {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\",\n    name: \"user.signup\"\n})"
                     },
                     {
                         "label": "CLI",
                         "lang": "Shell",
-                        "source": "svix event-type create '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"name\": \"user.signup\"\n}'"
+                        "source": "svix event-type create '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"featureFlag\": \"cool-new-feature\",\n    \"name\": \"user.signup\"\n}'"
                     },
                     {
                         "label": "cURL",
                         "lang": "Shell",
-                        "source": "curl -X 'POST' \\\n  'https://api.svix.com/api/v1/event-type/' \\\n  -H 'Authorization: Bearer AUTH_TOKEN' \\\n  -H 'accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"name\": \"user.signup\"\n}'"
+                        "source": "curl -X 'POST' \\\n  'https://api.svix.com/api/v1/event-type/' \\\n  -H 'Authorization: Bearer AUTH_TOKEN' \\\n  -H 'accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"featureFlag\": \"cool-new-feature\",\n    \"name\": \"user.signup\"\n}'"
                     }
                 ]
             }
@@ -13363,62 +13646,62 @@
                     {
                         "label": "JavaScript",
                         "lang": "JavaScript",
-                        "source": "const eventTypeOut = await svix.eventType.update('event_type_name', {\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false\n});"
+                        "source": "const eventTypeOut = await svix.eventType.update('event_type_name', {\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\"\n});"
                     },
                     {
                         "label": "TypeScript",
                         "lang": "JavaScript",
-                        "source": "const eventTypeOut = await svix.eventType.update('event_type_name', {\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false\n});"
+                        "source": "const eventTypeOut = await svix.eventType.update('event_type_name', {\n    description: \"A user has signed up\",\n    schemas: {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\"\n});"
                     },
                     {
                         "label": "Python",
                         "lang": "Python",
-                        "source": "event_type_out = svix.event_type.update('event_type_name', EventTypeUpdate(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False\n))"
+                        "source": "event_type_out = svix.event_type.update('event_type_name', EventTypeUpdate(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    feature_flag=\"cool-new-feature\"\n))"
                     },
                     {
                         "label": "Python (Async)",
                         "lang": "Python",
-                        "source": "event_type_out = await svix.event_type.update('event_type_name', EventTypeUpdate(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False\n))"
+                        "source": "event_type_out = await svix.event_type.update('event_type_name', EventTypeUpdate(\n    description=\"A user has signed up\",\n    schemas={'1': {'title': 'Invoice Paid Event', 'description': 'An invoice was paid by a user', 'type': 'object', 'properties': {'invoiceId': {'description': 'The invoice id', 'type': 'string'}, 'userId': {'description': 'The user id', 'type': 'string'}}, 'required': ['invoiceId', 'userId']}},\n    archived=False,\n    feature_flag=\"cool-new-feature\"\n))"
                     },
                     {
                         "label": "Go",
                         "lang": "Go",
-                        "source": "eventTypeOut, err := svixClient.EventType.Update(\"event_type_name\", &svix.EventTypeUpdate{\n    Description: \"A user has signed up\",\n    Schemas: map[string]interface{}{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    Archived: False\n})"
+                        "source": "eventTypeOut, err := svixClient.EventType.Update(\"event_type_name\", &svix.EventTypeUpdate{\n    Description: \"A user has signed up\",\n    Schemas: map[string]interface{}{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    Archived: False,\n    FeatureFlag: \"cool-new-feature\"\n})"
                     },
                     {
                         "label": "Kotlin",
                         "lang": "Kotlin",
-                        "source": "val eventTypeOut = svix.eventType.update('event_type_name', EventTypeUpdate()\n    .description(\"A user has signed up\"),\n    .schemas(\"\"\"{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }\"\"\"),\n    .archived(False)\n)"
+                        "source": "val eventTypeOut = svix.eventType.update('event_type_name', EventTypeUpdate()\n    .description(\"A user has signed up\"),\n    .schemas(\"\"\"{\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }\"\"\"),\n    .archived(False),\n    .featureFlag(\"cool-new-feature\")\n)"
                     },
                     {
                         "label": "Java",
                         "lang": "Java",
-                        "source": "EventTypeOut eventTypeOut = svix.getEventType().update('event_type_name', new EventTypeUpdate()\n    .description(\"A user has signed up\"),\n    .schemas(\"{\" +\n        \"\\\"1\\\": {\" +\n                \"\\\"title\\\": \\\"Invoice Paid Event\\\",\" +\n                \"\\\"description\\\": \\\"An invoice was paid by a user\\\",\" +\n                \"\\\"type\\\": \\\"object\\\",\" +\n                \"\\\"properties\\\": {\" +\n                        \"\\\"invoiceId\\\": {\" +\n                                \"\\\"description\\\": \\\"The invoice id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"},\" +\n                        \"\\\"userId\\\": {\" +\n                                \"\\\"description\\\": \\\"The user id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"}\" +\n                \"},\" +\n                \"\\\"required\\\": [\" +\n                        \"\\\"invoiceId\\\",\" +\n                        \"\\\"userId\\\"\" +\n                \"]\" +\n        \"}\" +\n    \"}\"),\n    .archived(False)\n)"
+                        "source": "EventTypeOut eventTypeOut = svix.getEventType().update('event_type_name', new EventTypeUpdate()\n    .description(\"A user has signed up\"),\n    .schemas(\"{\" +\n        \"\\\"1\\\": {\" +\n                \"\\\"title\\\": \\\"Invoice Paid Event\\\",\" +\n                \"\\\"description\\\": \\\"An invoice was paid by a user\\\",\" +\n                \"\\\"type\\\": \\\"object\\\",\" +\n                \"\\\"properties\\\": {\" +\n                        \"\\\"invoiceId\\\": {\" +\n                                \"\\\"description\\\": \\\"The invoice id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"},\" +\n                        \"\\\"userId\\\": {\" +\n                                \"\\\"description\\\": \\\"The user id\\\",\" +\n                                \"\\\"type\\\": \\\"string\\\"\" +\n                        \"}\" +\n                \"},\" +\n                \"\\\"required\\\": [\" +\n                        \"\\\"invoiceId\\\",\" +\n                        \"\\\"userId\\\"\" +\n                \"]\" +\n        \"}\" +\n    \"}\"),\n    .archived(False),\n    .featureFlag(\"cool-new-feature\")\n)"
                     },
                     {
                         "label": "Ruby",
                         "lang": "Ruby",
-                        "source": "event_type_out = svix.event_type.update('event_type_name', Svix::EventTypeUpdate.new({\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false\n}))"
+                        "source": "event_type_out = svix.event_type.update('event_type_name', Svix::EventTypeUpdate.new({\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"feature_flag\": \"cool-new-feature\"\n}))"
                     },
                     {
                         "label": "Rust",
                         "lang": "Rust",
-                        "source": "let event_type_out = svix.event_type().update(\"event_type_name\", EventTypeUpdate {\n    description: \"A user has signed up\".to_string(),\n    schemas: json!({\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    }),\n    archived: False\n}).await?;"
+                        "source": "let event_type_out = svix.event_type().update(\"event_type_name\", EventTypeUpdate {\n    description: \"A user has signed up\".to_string(),\n    schemas: Some(json!({\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    })),\n    archived: Some(False),\n    feature_flag: Some(\"cool-new-feature\".to_string())\n}, None).await?;"
                     },
                     {
                         "label": "C#",
                         "lang": "C#",
-                        "source": "var eventTypeOut = await svix.EventType.UpdateAsync(\"event_type_name\", new EventTypeUpdate{\n    description: \"A user has signed up\",\n    schemas: new {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false\n})"
+                        "source": "var eventTypeOut = await svix.EventType.UpdateAsync(\"event_type_name\", new EventTypeUpdate{\n    description: \"A user has signed up\",\n    schemas: new {\n        \"1\": {\n                \"title\": \"Invoice Paid Event\",\n                \"description\": \"An invoice was paid by a user\",\n                \"type\": \"object\",\n                \"properties\": {\n                        \"invoiceId\": {\n                                \"description\": \"The invoice id\",\n                                \"type\": \"string\"\n                        },\n                        \"userId\": {\n                                \"description\": \"The user id\",\n                                \"type\": \"string\"\n                        }\n                },\n                \"required\": [\n                        \"invoiceId\",\n                        \"userId\"\n                ]\n        }\n    },\n    archived: false,\n    featureFlag: \"cool-new-feature\"\n})"
                     },
                     {
                         "label": "CLI",
                         "lang": "Shell",
-                        "source": "svix event-type update 'event_type_name' '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false\n}'"
+                        "source": "svix event-type update 'event_type_name' '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"featureFlag\": \"cool-new-feature\"\n}'"
                     },
                     {
                         "label": "cURL",
                         "lang": "Shell",
-                        "source": "curl -X 'PUT' \\\n  'https://api.svix.com/api/v1/event-type/{event_type_name}/' \\\n  -H 'Authorization: Bearer AUTH_TOKEN' \\\n  -H 'accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false\n}'"
+                        "source": "curl -X 'PUT' \\\n  'https://api.svix.com/api/v1/event-type/{event_type_name}/' \\\n  -H 'Authorization: Bearer AUTH_TOKEN' \\\n  -H 'accept: application/json' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"description\": \"A user has signed up\",\n    \"schemas\": {\n        \"1\": {\n            \"title\": \"Invoice Paid Event\",\n            \"description\": \"An invoice was paid by a user\",\n            \"type\": \"object\",\n            \"properties\": {\n                \"invoiceId\": {\n                    \"description\": \"The invoice id\",\n                    \"type\": \"string\"\n                },\n                \"userId\": {\n                    \"description\": \"The user id\",\n                    \"type\": \"string\"\n                }\n            },\n            \"required\": [\n                \"invoiceId\",\n                \"userId\"\n            ]\n        }\n    },\n    \"archived\": false,\n    \"featureFlag\": \"cool-new-feature\"\n}'"
                     }
                 ]
             }
diff --git a/python/svix/__init__.py b/python/svix/__init__.py
index 22e307f76..97436c915 100644
--- a/python/svix/__init__.py
+++ b/python/svix/__init__.py
@@ -37,4 +37,4 @@
     "WebhookVerificationError",
 ]
 
-__version__ = "0.72.0"
+__version__ = "0.75.0"
diff --git a/python/svix/api.py b/python/svix/api.py
index 93a5a28da..8979b2f80 100644
--- a/python/svix/api.py
+++ b/python/svix/api.py
@@ -12,6 +12,7 @@
     update_application_api_v1_app_app_id_put,
 )
 from .internal.openapi_client.api.authentication import (
+    get_app_portal_access_api_v1_auth_app_portal_access_app_id_post,
     get_dashboard_access_api_v1_auth_dashboard_access_app_id_post,
     logout_api_v1_auth_logout_post,
 )
@@ -60,6 +61,8 @@
     resend_webhook_api_v1_app_app_id_msg_msg_id_endpoint_endpoint_id_resend_post,
 )
 from .internal.openapi_client.client import AuthenticatedClient
+from .internal.openapi_client.models.app_portal_access_in import AppPortalAccessIn
+from .internal.openapi_client.models.app_portal_access_out import AppPortalAccessOut
 from .internal.openapi_client.models.application_in import ApplicationIn
 from .internal.openapi_client.models.application_out import ApplicationOut
 from .internal.openapi_client.models.dashboard_access_out import DashboardAccessOut
@@ -174,6 +177,13 @@ def __init__(self, client: AuthenticatedClient) -> None:
 
 
 class AuthenticationAsync(ApiBase):
+    async def app_portal_access(
+        self, app_id: str, app_portal_access_in: AppPortalAccessIn, options: PostOptions = PostOptions()
+    ) -> AppPortalAccessOut:
+        return await get_app_portal_access_api_v1_auth_app_portal_access_app_id_post.asyncio(
+            client=self._client, app_id=app_id, json_body=app_portal_access_in, **options.to_dict()
+        )
+
     async def dashboard_access(self, app_id: str, options: PostOptions = PostOptions()) -> DashboardAccessOut:
         return await get_dashboard_access_api_v1_auth_dashboard_access_app_id_post.asyncio(
             client=self._client, app_id=app_id, **options.to_dict()
@@ -184,6 +194,13 @@ async def logout(self, options: PostOptions = PostOptions()) -> None:
 
 
 class Authentication(ApiBase):
+    def app_portal_access(
+        self, app_id: str, app_portal_access_in: AppPortalAccessIn, options: PostOptions = PostOptions()
+    ) -> AppPortalAccessOut:
+        return get_app_portal_access_api_v1_auth_app_portal_access_app_id_post.sync(
+            client=self._client, app_id=app_id, json_body=app_portal_access_in, **options.to_dict()
+        )
+
     def dashboard_access(self, app_id: str, options: PostOptions = PostOptions()) -> DashboardAccessOut:
         return get_dashboard_access_api_v1_auth_dashboard_access_app_id_post.sync(
             client=self._client, app_id=app_id, **options.to_dict()
diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock
index b9d74f153..d8742bb48 100644
--- a/ruby/Gemfile.lock
+++ b/ruby/Gemfile.lock
@@ -1,7 +1,7 @@
 PATH
   remote: .
   specs:
-    svix (0.72.0)
+    svix (0.75.0)
       typhoeus (~> 1.0, >= 1.0.1)
 
 GEM
diff --git a/ruby/lib/svix/authentication_api.rb b/ruby/lib/svix/authentication_api.rb
index 438cfb09f..da2effb42 100644
--- a/ruby/lib/svix/authentication_api.rb
+++ b/ruby/lib/svix/authentication_api.rb
@@ -1,17 +1,21 @@
 # frozen_string_literal: true
 
 module Svix
-    class AuthenticationAPI
-        def initialize(api_client)
-            @api = AuthenticationApi.new(api_client)
-        end
+  class AuthenticationAPI
+    def initialize(api_client)
+      @api = AuthenticationApi.new(api_client)
+    end
+
+    def app_portal_access(app_id, app_portal_access_in, options = {})
+      return @api.get_app_portal_access_access_api_v1_auth_dashboard_access_app_id_post(app_id, app_portal_access_in, options)
+    end
 
-        def dashboard_access(app_id, options = {})
-            return @api.get_dashboard_access_api_v1_auth_dashboard_access_app_id_post(app_id, options)
-        end
+    def dashboard_access(app_id, options = {})
+      return @api.get_dashboard_access_api_v1_auth_dashboard_access_app_id_post(app_id, options)
+    end
 
-        def logout(options = {})
-            return @api.logout_api_v1_auth_logout_post(options)
-        end
+    def logout(options = {})
+      return @api.logout_api_v1_auth_logout_post(options)
     end
+  end
 end
diff --git a/ruby/lib/svix/version.rb b/ruby/lib/svix/version.rb
index 4d8f21a7d..e43c3bd00 100644
--- a/ruby/lib/svix/version.rb
+++ b/ruby/lib/svix/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Svix
-  VERSION = "0.72.0"
+  VERSION = "0.75.0"
 end
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 0f1a0a8c1..cc5a5d175 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "svix"
-version = "0.72.0"
+version = "0.75.0"
 authors = ["Svix Inc. <oss@svix.com>"]
 edition = "2021"
 description="Rust library for interacting with the Svix API and verifying webhook signatures."
@@ -25,4 +25,5 @@ serde = "^1.0"
 serde_derive = "^1.0"
 serde_json = "^1.0"
 url = "^2.2"
-reqwest = { version = "^0.11", features = ["json", "multipart"], default-features = false }
+reqwest = { version = "^0.11", features = ["json", "multipart", "default-tls"], default-features = false }
+
diff --git a/rust/src/api.rs b/rust/src/api.rs
index afe265cb4..822268cb0 100644
--- a/rust/src/api.rs
+++ b/rust/src/api.rs
@@ -4,6 +4,10 @@ use crate::apis::{
     message_api, message_attempt_api,
 };
 use crate::error::Result;
+use crate::models::{
+    AppPortalAccessIn, AppPortalAccessOut, IntegrationIn, IntegrationKeyOut, IntegrationOut,
+    IntegrationUpdate, ListResponseIntegrationOut,
+};
 pub use crate::models::{
     ApplicationIn, ApplicationOut, DashboardAccessOut, EndpointHeadersIn, EndpointHeadersOut,
     EndpointHeadersPatchIn, EndpointIn, EndpointOut, EndpointSecretOut, EndpointSecretRotateIn,
@@ -13,9 +17,6 @@ pub use crate::models::{
     ListResponseMessageEndpointOut, ListResponseMessageOut, MessageAttemptOut, MessageIn,
     MessageOut, MessageStatus, RecoverIn, StatusCodeClass,
 };
-use crate::models::{
-    IntegrationIn, IntegrationKeyOut, IntegrationOut, IntegrationUpdate, ListResponseIntegrationOut,
-};
 
 const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
 
@@ -81,7 +82,7 @@ impl Svix {
 
 #[derive(Default)]
 pub struct PostOptions {
-    idempotency_key: Option<String>,
+    pub idempotency_key: Option<String>,
 }
 
 pub struct Authentication<'a> {
@@ -111,6 +112,26 @@ impl<'a> Authentication<'a> {
         )
     }
 
+    pub async fn app_portal_access(
+        &self,
+        app_id: String,
+        app_portal_access_in: AppPortalAccessIn,
+        options: Option<PostOptions>,
+    ) -> Result<AppPortalAccessOut> {
+        let options = options.unwrap_or_default();
+        Ok(
+            authentication_api::get_app_portal_access_api_v1_auth_app_portal_access_app_id_post(
+                self.cfg,
+                authentication_api::GetAppPortalAccessApiV1AuthAppPortalAccessAppIdPostParams {
+                    app_id,
+                    app_portal_access_in,
+                    idempotency_key: options.idempotency_key,
+                },
+            )
+            .await?,
+        )
+    }
+
     pub async fn logout(&self, options: Option<PostOptions>) -> Result<()> {
         let PostOptions { idempotency_key } = options.unwrap_or_default();
         Ok(authentication_api::logout_api_v1_auth_logout_post(
@@ -123,8 +144,8 @@ impl<'a> Authentication<'a> {
 
 #[derive(Default)]
 pub struct ListOptions {
-    iterator: Option<String>,
-    limit: Option<i32>,
+    pub iterator: Option<String>,
+    pub limit: Option<i32>,
 }
 
 pub type ApplicationListOptions = ListOptions;
@@ -592,10 +613,10 @@ impl<'a> Integration<'a> {
 
 #[derive(Default)]
 pub struct EventTypeListOptions {
-    iterator: Option<String>,
-    limit: Option<i32>,
-    with_content: Option<bool>,
-    include_archived: Option<bool>,
+    pub iterator: Option<String>,
+    pub limit: Option<i32>,
+    pub with_content: Option<bool>,
+    pub include_archived: Option<bool>,
 }
 
 pub struct EventType<'a> {
@@ -695,15 +716,15 @@ impl<'a> EventType<'a> {
 
 #[derive(Default)]
 pub struct MessageListOptions {
-    iterator: Option<String>,
-    limit: Option<i32>,
-    event_types: Option<Vec<String>>,
+    pub iterator: Option<String>,
+    pub limit: Option<i32>,
+    pub event_types: Option<Vec<String>>,
     // FIXME: make before and after actual dates
     /// RFC3339 date string
-    before: Option<String>,
+    pub before: Option<String>,
     /// RFC3339 date string
-    after: Option<String>,
-    channel: Option<String>,
+    pub after: Option<String>,
+    pub channel: Option<String>,
 }
 
 pub struct Message<'a> {
@@ -778,17 +799,17 @@ impl<'a> Message<'a> {
 
 #[derive(Default)]
 pub struct MessageAttemptListOptions {
-    iterator: Option<String>,
-    limit: Option<i32>,
-    event_types: Option<Vec<String>>,
+    pub iterator: Option<String>,
+    pub limit: Option<i32>,
+    pub event_types: Option<Vec<String>>,
     // FIXME: make before and after actual dates
     /// RFC3339 date string
-    before: Option<String>,
+    pub before: Option<String>,
     /// RFC3339 date string
-    after: Option<String>,
-    channel: Option<String>,
-    status: Option<MessageStatus>,
-    status_code_class: Option<StatusCodeClass>,
+    pub after: Option<String>,
+    pub channel: Option<String>,
+    pub status: Option<MessageStatus>,
+    pub status_code_class: Option<StatusCodeClass>,
 }
 
 pub struct MessageAttempt<'a> {
diff --git a/rust/src/webhooks.rs b/rust/src/webhooks.rs
index b9a114c02..964c17c60 100644
--- a/rust/src/webhooks.rs
+++ b/rust/src/webhooks.rs
@@ -46,8 +46,8 @@ const TOLERANCE_IN_SECONDS: i64 = 5 * 60;
 const SIGNATURE_VERSION: &str = "v1";
 
 impl Webhook {
-    pub fn new(secret: String) -> Result<Self, WebhookError> {
-        let secret = secret.strip_prefix(PREFIX).unwrap_or(&secret);
+    pub fn new(secret: &str) -> Result<Self, WebhookError> {
+        let secret = secret.strip_prefix(PREFIX).unwrap_or(secret);
         let key = base64::decode(secret)?;
 
         Ok(Webhook { key })
@@ -177,7 +177,7 @@ mod tests {
 
     #[test]
     fn test_sign() {
-        let wh = Webhook::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned()).unwrap();
+        let wh = Webhook::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD").unwrap();
         assert_eq!(
             "v1,tZ1I4/hDygAJgO5TYxiSd6Sd0kDW6hPenDe+bTa3Kkw=".to_owned(),
             wh.sign(
@@ -194,7 +194,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let signature = wh
             .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
@@ -212,7 +212,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let signature = "v1,R3PTzyfHASBKHH98a7yexTwaJ4yNIcGhFQc1yuN+BPU=".to_owned();
         for headers in [
@@ -228,7 +228,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let signature = wh
             .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
@@ -253,7 +253,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let signature = wh
             .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
@@ -277,7 +277,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let missing_sig = format!(
             "{} {} {}",
@@ -296,7 +296,7 @@ mod tests {
         let secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD".to_owned();
         let msg_id = "msg_27UH4WbU6Z5A5EzD8u03UvzRbpk";
         let payload = br#"{"email":"test@example.com","username":"test_user"}"#;
-        let wh = Webhook::new(secret).unwrap();
+        let wh = Webhook::new(&secret).unwrap();
 
         let signature = wh
             .sign(msg_id, OffsetDateTime::now_utc().unix_timestamp(), payload)
diff --git a/server/Cargo.lock b/server/Cargo.lock
index 3e265b298..11fb614c5 100644
--- a/server/Cargo.lock
+++ b/server/Cargo.lock
@@ -50,6 +50,39 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "aide"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "befdff0b4683a0824fc8719ce639a252d9d62cd89c8d0004c39e2417128c1eb8"
+dependencies = [
+ "aide-macros",
+ "axum",
+ "bytes",
+ "cfg-if",
+ "http",
+ "indexmap",
+ "schemars",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "aide-macros"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae9102630967c9c07c40a3383cd1e6b043bdd203793731ad39eac7f7dce0d4c"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "aliasable"
 version = "0.1.3"
@@ -685,6 +718,41 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "darling"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0dd3cd20dc6b5a876612a6e5accfe7f3dd883db6d07acfbf14c128f61550dfa"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a784d2ccaf7c98501746bf0be29b2022ba41fd62a2e622af997a03e9f972859f"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "data-encoding"
 version = "2.3.2"
@@ -763,6 +831,12 @@ version = "0.15.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0"
 
+[[package]]
+name = "dyn-clone"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9b0705efd4599c15a38151f4721f7bc388306f61084d3bfd50bd07fbca5cb60"
+
 [[package]]
 name = "ecdsa"
 version = "0.13.4"
@@ -1303,8 +1377,7 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
 [[package]]
 name = "hyper"
 version = "0.14.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c"
+source = "git+https://github.com/svix/hyper/?rev=b901ca7c#b901ca7c7772c427d63d150e1bf1c2ce7ce0d733"
 dependencies = [
  "bytes",
  "futures-channel",
@@ -1324,6 +1397,24 @@ dependencies = [
  "want",
 ]
 
+[[package]]
+name = "hyper-openssl"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6ee5d7a8f718585d1c3c61dfde28ef5b0bb14734b4db13f5ada856cdc6c612b"
+dependencies = [
+ "http",
+ "hyper",
+ "linked_hash_set",
+ "once_cell",
+ "openssl",
+ "openssl-sys",
+ "parking_lot 0.12.1",
+ "tokio",
+ "tokio-openssl",
+ "tower-layer",
+]
+
 [[package]]
 name = "hyper-rustls"
 version = "0.23.1"
@@ -1373,6 +1464,12 @@ dependencies = [
  "cxx-build",
 ]
 
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
 [[package]]
 name = "idna"
 version = "0.2.3"
@@ -1408,6 +1505,7 @@ checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399"
 dependencies = [
  "autocfg 1.1.0",
  "hashbrown",
+ "serde",
 ]
 
 [[package]]
@@ -1452,6 +1550,9 @@ name = "ipnet"
 version = "2.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f88c5561171189e69df9d98bcf18fd5f9558300f7ea7b801eb8a0fd748bd8745"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "is-terminal"
@@ -1599,6 +1700,15 @@ version = "0.5.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
 
+[[package]]
+name = "linked_hash_set"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47186c6da4d81ca383c7c47c1bfc80f4b95f4720514d860a5407aaf4233f9588"
+dependencies = [
+ "linked-hash-map",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.1.3"
@@ -1899,9 +2009,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
 [[package]]
 name = "openssl"
-version = "0.10.43"
+version = "0.10.45"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020433887e44c27ff16365eaa2d380547a94544ad509aff6eb5b6e3e0b27b376"
+checksum = "b102428fd03bc5edf97f62620f7298614c45cedf287c271e7ed450bbaf83f2e1"
 dependencies = [
  "bitflags",
  "cfg-if",
@@ -1931,9 +2041,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
 
 [[package]]
 name = "openssl-sys"
-version = "0.9.78"
+version = "0.9.80"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07d5c8cb6e57b3a3612064d7b18b117912b4ce70955c2504d4b741c9e244b132"
+checksum = "23bbbf7854cd45b83958ebe919f0e8e516793727652e27fda10a8384cfc790b7"
 dependencies = [
  "autocfg 1.1.0",
  "cc",
@@ -2641,6 +2751,33 @@ dependencies = [
  "windows-sys 0.36.1",
 ]
 
+[[package]]
+name = "schemars"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a5fb6c61f29e723026dc8e923d94c694313212abbecbbe5f55a7748eec5b307"
+dependencies = [
+ "chrono",
+ "dyn-clone",
+ "indexmap",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f188d036977451159430f3b8dc82ec76364a42b7e289c2b18a9a18f4470058e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn",
+]
+
 [[package]]
 name = "scopeguard"
 version = "1.1.0"
@@ -2816,6 +2953,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_derive_internals"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "serde_json"
 version = "1.0.89"
@@ -3098,6 +3246,28 @@ version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
 
+[[package]]
+name = "strum"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.24.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
+dependencies = [
+ "heck 0.4.0",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
 [[package]]
 name = "subtle"
 version = "2.4.1"
@@ -3136,8 +3306,9 @@ dependencies = [
 
 [[package]]
 name = "svix-server"
-version = "0.72.0"
+version = "0.75.0"
 dependencies = [
+ "aide",
  "anyhow",
  "axum",
  "base64",
@@ -3156,10 +3327,14 @@ dependencies = [
  "hmac-sha256",
  "http",
  "hyper",
+ "hyper-openssl",
+ "indexmap",
+ "ipnet",
  "jsonschema",
  "jwt-simple",
  "lazy_static",
  "num_enum",
+ "openssl",
  "opentelemetry",
  "opentelemetry-http",
  "opentelemetry-otlp",
@@ -3169,6 +3344,7 @@ dependencies = [
  "redis_cluster_async",
  "regex",
  "reqwest",
+ "schemars",
  "sea-orm",
  "serde",
  "serde_json",
@@ -3176,6 +3352,8 @@ dependencies = [
  "serde_urlencoded",
  "sha2 0.10.6",
  "sqlx",
+ "strum",
+ "strum_macros",
  "svix",
  "svix-ksuid",
  "svix-server_derive",
@@ -3187,7 +3365,9 @@ dependencies = [
  "tracing",
  "tracing-opentelemetry",
  "tracing-subscriber",
+ "trust-dns-resolver",
  "url",
+ "urlencoding",
  "validator",
 ]
 
@@ -3341,9 +3521,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
 
 [[package]]
 name = "tokio"
-version = "1.22.0"
+version = "1.23.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d76ce4a75fb488c605c54bf610f221cea8b0dafb53333c1a67e8ee199dcd2ae3"
+checksum = "38a54aca0c15d014013256222ba0ebed095673f89345dd79119d912eb561b7a8"
 dependencies = [
  "autocfg 1.1.0",
  "bytes",
@@ -3356,7 +3536,7 @@ dependencies = [
  "signal-hook-registry",
  "socket2",
  "tokio-macros",
- "winapi",
+ "windows-sys 0.42.0",
 ]
 
 [[package]]
@@ -3390,6 +3570,18 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "tokio-openssl"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08f9ffb7809f1b20c1b398d92acf4cc719874b3b2b2d9ea2f09b4a80350878a"
+dependencies = [
+ "futures-util",
+ "openssl",
+ "openssl-sys",
+ "tokio",
+]
+
 [[package]]
 name = "tokio-rustls"
 version = "0.23.4"
@@ -3790,8 +3982,15 @@ dependencies = [
  "form_urlencoded",
  "idna 0.3.0",
  "percent-encoding",
+ "serde",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
+
 [[package]]
 name = "uuid"
 version = "1.2.2"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 023cd8eca..252946c0b 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -1,2 +1,5 @@
 [workspace]
 members = ["svix-server", "svix-server_derive"]
+
+[patch.crates-io]
+hyper = { git = "https://github.com/svix/hyper/", rev = "b901ca7c" }
diff --git a/server/Dockerfile b/server/Dockerfile
index b42b65058..27f0a40d3 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -36,6 +36,11 @@ RUN set -ex ; \
         mkdir -p /home/appuser ;\
         chown -R appuser: /home/appuser
 
+RUN apt-get update ;\
+    apt-get install --no-install-recommends -y ca-certificates=20210119; \
+    update-ca-certificates; \
+    rm -rf /var/lib/apt/lists/*
+
 USER appuser
 EXPOSE 8080
 
diff --git a/server/svix-server/Cargo.toml b/server/svix-server/Cargo.toml
index 6db88bff7..d82012a41 100644
--- a/server/svix-server/Cargo.toml
+++ b/server/svix-server/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "svix-server"
-version = "0.72.0"
+version = "0.75.0"
 license = "MIT"
 description = "Svix webhooks server"
 
@@ -23,7 +23,9 @@ clap = { version = "3.2.1", features = ["derive"] }
 axum = { version = "0.6.1", features = ["headers"] }
 base64 = "0.13.0"
 hyper = { version = "0.14.16", features = ["full"] }
-tokio = { version = "1.15.0", features = ["full"] }
+hyper-openssl = "0.9.2"
+openssl = "0.10.45"
+tokio = { version = "1.23.1", features = ["full"] }
 tower = "0.4.11"
 tower-http = { version = "0.3.4", features = ["trace", "cors", "request-id"] }
 serde = { version = "1.0.133", features = ["derive"] }
@@ -61,9 +63,17 @@ http = "0.2"
 time = { version = "0.3.9", features = [ "std" ]}
 futures = "0.3"
 redis_cluster_async = { git = "https://github.com/redis-rs/redis-cluster-async.git", rev = "e6fe168" }
-url = "2.2.2"
+url = { version = "2.2.2", features = ["serde"] }
 rand = "0.8.5"
 jsonschema = "0.16.1"
+aide = { version = "0.9.0", features = ["axum", "redoc", "macros"] }
+schemars = { version = "0.8.11", features = ["chrono", "url"] }
+indexmap = "1.9.2"
+trust-dns-resolver = "0.22.0"
+ipnet = { version = "2.5", features = ["serde"] }
+urlencoding = "2.1.2"
+strum_macros = "0.24"
+strum = { version = "0.24", features = ["derive"] }
 
 [dev-dependencies]
 anyhow = "1.0.56"
diff --git a/server/svix-server/config.default.toml b/server/svix-server/config.default.toml
index dd488648a..ce6cd2c5f 100644
--- a/server/svix-server/config.default.toml
+++ b/server/svix-server/config.default.toml
@@ -94,3 +94,11 @@ api_enabled = true
 
 # Should this instance run the message worker
 worker_enabled = true
+
+# Subnets to whitelist for outbound webhooks. Note that allowing endpoints in private IP space
+# is a security risk and should only be allowed if you are using the service internally or for
+# testing purposes. Should be specified in CIDR notation, e.g., `[127.0.0.1/32, 172.17.0.0/16, 192.168.0.0/16]`
+# whitelist_subnets = []
+
+# Maximum number of concurrent worker tasks to spawn (0 is unlimited)
+worker_max_tasks = 500
\ No newline at end of file
diff --git a/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.down.sql b/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.down.sql
new file mode 100644
index 000000000..12c026833
--- /dev/null
+++ b/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.down.sql
@@ -0,0 +1,2 @@
+-- Add down migration script here
+ALTER TABLE ONLY eventtype DROP COLUMN feature_flag;
diff --git a/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.up.sql b/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.up.sql
new file mode 100644
index 000000000..3265ad825
--- /dev/null
+++ b/server/svix-server/migrations/20230116095641_add_event_type_feature_flags.up.sql
@@ -0,0 +1,2 @@
+-- Add up migration script here
+ALTER TABLE ONLY eventtype ADD COLUMN feature_flag text;
diff --git a/server/svix-server/src/cfg.rs b/server/svix-server/src/cfg.rs
index 9610d7b7b..4f3db9701 100644
--- a/server/svix-server/src/cfg.rs
+++ b/server/svix-server/src/cfg.rs
@@ -7,6 +7,7 @@ use figment::{
     providers::{Env, Format, Toml},
     Figment,
 };
+use ipnet::IpNet;
 use std::time::Duration;
 
 use crate::{core::cryptography::Encryption, core::security::Keys, error::Result};
@@ -53,7 +54,6 @@ where
         }
         RetryScheduleDeserializer::Legacy(buf) => Ok(buf
             .split(',')
-            .into_iter()
             .filter_map(|x| {
                 let x = x.trim();
                 if x.is_empty() {
@@ -173,6 +173,14 @@ pub struct ConfigurationInner {
     /// Should this instance run the message worker
     pub worker_enabled: bool,
 
+    /// Subnets to whitelist for outbound webhooks. Note that allowing endpoints in private IP space
+    /// is a security risk and should only be allowed if you are using the service internally or for
+    /// testing purposes. Should be specified in CIDR notation, e.g., `[127.0.0.1/32, 172.17.0.0/16, 192.168.0.0/16]`
+    pub whitelist_subnets: Option<Arc<Vec<IpNet>>>,
+
+    /// Maximum number of concurrent worker tasks to spawn (0 is unlimited)
+    pub worker_max_tasks: u16,
+
     #[serde(flatten)]
     pub internal: InternalConfig,
 }
diff --git a/server/svix-server/src/core/idempotency.rs b/server/svix-server/src/core/idempotency.rs
index 8aaa25756..ca61d3826 100644
--- a/server/svix-server/src/core/idempotency.rs
+++ b/server/svix-server/src/core/idempotency.rs
@@ -348,7 +348,7 @@ where
 mod tests {
     use std::{net::TcpListener, sync::Arc};
 
-    use axum::{extract::Extension, routing::post, Router, Server};
+    use axum::{extract::State, routing::post, Router, Server};
     use http::StatusCode;
     use reqwest::Client;
     use tokio::{sync::Mutex, task::JoinHandle};
@@ -361,6 +361,12 @@ mod tests {
         types::{BaseId, OrganizationId},
     };
 
+    #[derive(Clone)]
+    struct TestAppState {
+        count: Arc<Mutex<u16>>,
+        wait: Option<std::time::Duration>,
+    }
+
     /// Starts a basic Axum server with one endpoint which counts the number of times the endpoint
     /// has been polled from. This will be nested in the [`IdempotencyService`] such that, providing
     /// a key may result in the count not increasing and a prior result being displayed.
@@ -372,7 +378,7 @@ mod tests {
     /// that points to the count of the server such that its internal state may be monitored.
     async fn start_service(
         wait: Option<std::time::Duration>,
-    ) -> (JoinHandle<()>, String, Arc<Mutex<usize>>) {
+    ) -> (JoinHandle<()>, String, Arc<Mutex<u16>>) {
         dotenv::dotenv().ok();
 
         let cache = cache::memory::new();
@@ -396,8 +402,7 @@ mod tests {
                                     service,
                                 }
                             }))
-                            .layer(Extension(count))
-                            .layer(Extension(wait))
+                            .with_state(TestAppState { count, wait })
                             .into_make_service(),
                     )
                     .await
@@ -409,10 +414,7 @@ mod tests {
     }
 
     /// Only to be used via [`start_service`] -- this is the actual endpoint implementation
-    async fn service_endpoint(
-        Extension(count): Extension<Arc<Mutex<usize>>>,
-        Extension(wait): Extension<Option<std::time::Duration>>,
-    ) -> String {
+    async fn service_endpoint(State(TestAppState { wait, count }): State<TestAppState>) -> String {
         let mut count = count.lock().await;
         *count += 1;
 
@@ -597,7 +599,7 @@ mod tests {
                                     service,
                                 }
                             }))
-                            .layer(Extension(count))
+                            .with_state(TestAppState { count, wait: None })
                             .into_make_service(),
                     )
                     .await
@@ -609,7 +611,9 @@ mod tests {
     }
 
     /// Only to be used via [`start_empty_service`] -- this is the actual endpoint implementation
-    async fn empty_service_endpoint(Extension(count): Extension<Arc<Mutex<u16>>>) -> StatusCode {
+    async fn empty_service_endpoint(
+        State(TestAppState { count, .. }): State<TestAppState>,
+    ) -> StatusCode {
         let mut count = count.lock().await;
         *count += 1;
 
diff --git a/server/svix-server/src/core/message_app.rs b/server/svix-server/src/core/message_app.rs
index d0fc6bc6f..ae1041fd4 100644
--- a/server/svix-server/src/core/message_app.rs
+++ b/server/svix-server/src/core/message_app.rs
@@ -67,7 +67,7 @@ impl CreateMessageApp {
     /// exists or from PostgreSQL otherwise. If the RedisCache is Some, but does not contain the
     /// requisite information, fetch it from PostgreSQL and insert the data into the cache.
     pub async fn layered_fetch(
-        cache: Cache,
+        cache: &Cache,
         pg: &DatabaseConnection,
         app: Option<application::Model>,
         org_id: OrganizationId,
diff --git a/server/svix-server/src/core/mod.rs b/server/svix-server/src/core/mod.rs
index cf5097c49..3660b7d81 100644
--- a/server/svix-server/src/core/mod.rs
+++ b/server/svix-server/src/core/mod.rs
@@ -11,6 +11,7 @@ pub mod permissions;
 pub mod run_with_retries;
 pub mod security;
 pub mod types;
+pub mod webhook_http_client;
 
 #[cfg(test)]
 mod tests {
diff --git a/server/svix-server/src/core/operational_webhooks.rs b/server/svix-server/src/core/operational_webhooks.rs
index 53303dce9..435e62725 100644
--- a/server/svix-server/src/core/operational_webhooks.rs
+++ b/server/svix-server/src/core/operational_webhooks.rs
@@ -25,47 +25,47 @@ use crate::{
 /// Sent when an endpoint has been automatically disabled after continuous failures.
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
-pub struct EndpointDisabledEvent<'a> {
-    pub app_id: &'a ApplicationId,
-    pub app_uid: Option<&'a ApplicationUid>,
-    pub endpoint_id: &'a EndpointId,
-    pub endpoint_uid: Option<&'a EndpointUid>,
+pub struct EndpointDisabledEvent {
+    pub app_id: ApplicationId,
+    pub app_uid: Option<ApplicationUid>,
+    pub endpoint_id: EndpointId,
+    pub endpoint_uid: Option<EndpointUid>,
     pub fail_since: DateTime<Utc>,
 }
 
 /// Sent when an endpoint is created, updated, or deleted
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
-pub struct EndpointEvent<'a> {
-    pub app_id: &'a ApplicationId,
-    pub app_uid: Option<&'a ApplicationUid>,
-    pub endpoint_id: &'a EndpointId,
-    pub endpoint_uid: Option<&'a EndpointUid>,
+pub struct EndpointEvent {
+    pub app_id: ApplicationId,
+    pub app_uid: Option<ApplicationUid>,
+    pub endpoint_id: EndpointId,
+    pub endpoint_uid: Option<EndpointUid>,
 }
 
-impl<'a> EndpointEvent<'a> {
-    pub fn new(app_uid: Option<&'a ApplicationUid>, endp: &'a endpoint::Model) -> Self {
+impl EndpointEvent {
+    pub fn new(app_uid: Option<&ApplicationUid>, endp: &endpoint::Model) -> Self {
         Self {
-            app_id: &endp.app_id,
-            app_uid,
-            endpoint_id: &endp.id,
-            endpoint_uid: endp.uid.as_ref(),
+            app_id: endp.app_id.clone(),
+            app_uid: app_uid.cloned(),
+            endpoint_id: endp.id.clone(),
+            endpoint_uid: endp.uid.clone(),
         }
     }
 }
 
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
-pub struct MessageAttempetLast<'a> {
-    pub id: &'a MessageAttemptId,
+pub struct MessageAttempetLast {
+    pub id: MessageAttemptId,
     pub response_status_code: i16,
     pub timestamp: DateTime<Utc>,
 }
 
-impl<'a> From<&'a messageattempt::Model> for MessageAttempetLast<'a> {
-    fn from(attempt: &'a messageattempt::Model) -> Self {
+impl From<messageattempt::Model> for MessageAttempetLast {
+    fn from(attempt: messageattempt::Model) -> Self {
         Self {
-            id: &attempt.id,
+            id: attempt.id,
             response_status_code: attempt.response_status_code,
             timestamp: attempt.created_at.into(),
         }
@@ -77,30 +77,30 @@ impl<'a> From<&'a messageattempt::Model> for MessageAttempetLast<'a> {
 /// event.
 #[derive(Debug, Serialize)]
 #[serde(rename_all = "camelCase")]
-pub struct MessageAttemptEvent<'a> {
-    pub app_id: &'a ApplicationId,
-    pub app_uid: Option<&'a ApplicationUid>,
-    pub msg_id: &'a MessageId,
-    pub msg_event_id: Option<&'a MessageUid>,
-    pub endpoint_id: &'a EndpointId,
-    pub last_attempt: MessageAttempetLast<'a>,
+pub struct MessageAttemptEvent {
+    pub app_id: ApplicationId,
+    pub app_uid: Option<ApplicationUid>,
+    pub msg_id: MessageId,
+    pub msg_event_id: Option<MessageUid>,
+    pub endpoint_id: EndpointId,
+    pub last_attempt: MessageAttempetLast,
 }
 
 #[derive(Debug, Serialize)]
 #[serde(tag = "type", content = "data")]
-pub enum OperationalWebhook<'a> {
+pub enum OperationalWebhook {
     #[serde(rename = "endpoint.disabled")]
-    EndpointDisabled(EndpointDisabledEvent<'a>),
+    EndpointDisabled(EndpointDisabledEvent),
     #[serde(rename = "endpoint.created")]
-    EndpointCreated(EndpointEvent<'a>),
+    EndpointCreated(EndpointEvent),
     #[serde(rename = "endpoint.updated")]
-    EndpointUpdated(EndpointEvent<'a>),
+    EndpointUpdated(EndpointEvent),
     #[serde(rename = "endpoint.deleted")]
-    EndpointDeleted(EndpointEvent<'a>),
+    EndpointDeleted(EndpointEvent),
     #[serde(rename = "message.attempt.exhausted")]
-    MessageAttemptExhausted(MessageAttemptEvent<'a>),
+    MessageAttemptExhausted(MessageAttemptEvent),
     #[serde(rename = "message.attempt.failing")]
-    MessageAttemptFailing(MessageAttemptEvent<'a>),
+    MessageAttemptFailing(MessageAttemptEvent),
 }
 
 pub type OperationalWebhookSender = Arc<OperationalWebhookSenderInner>;
@@ -118,7 +118,7 @@ impl OperationalWebhookSenderInner {
     pub async fn send_operational_webhook(
         &self,
         recipient_org_id: &OrganizationId,
-        payload: OperationalWebhook<'_>,
+        payload: OperationalWebhook,
     ) -> Result<()> {
         let url = match self.url.as_ref() {
             Some(url) => url,
diff --git a/server/svix-server/src/core/otel_spans.rs b/server/svix-server/src/core/otel_spans.rs
index e25a7f528..aa7e764ac 100644
--- a/server/svix-server/src/core/otel_spans.rs
+++ b/server/svix-server/src/core/otel_spans.rs
@@ -125,6 +125,8 @@ impl<B> MakeSpan<B> for AxumOtelSpanCreator {
             request_id = %request_id,
             trace_id = %trace_id,
             idempotency_key = tracing::field::Empty,
+            org_id = tracing::field::Empty,
+            app_id = tracing::field::Empty,
         );
 
         if let Some(key) = idempotency_key {
diff --git a/server/svix-server/src/core/permissions.rs b/server/svix-server/src/core/permissions.rs
index c9ef0ed29..eba495c0e 100644
--- a/server/svix-server/src/core/permissions.rs
+++ b/server/svix-server/src/core/permissions.rs
@@ -1,44 +1,53 @@
+use aide::OperationInput;
 use axum::{
     async_trait,
     extract::{FromRequestParts, Path},
     http::request::Parts,
-    Extension,
 };
-use sea_orm::DatabaseConnection;
 
 use crate::{
     ctx,
     db::models::{application, applicationmetadata},
     error::{Error, HttpError, Result},
+    AppState,
 };
 
 use super::{
     security::{permissions_from_bearer, AccessLevel, Permissions},
-    types::{ApplicationId, ApplicationIdOrUid, OrganizationId},
+    types::{ApplicationId, ApplicationIdOrUid, FeatureFlagSet, OrganizationId},
 };
 
 pub struct ReadAll {
     pub org_id: OrganizationId,
+    pub feature_flags: AllowedFeatureFlags,
 }
 
 #[async_trait]
-impl<S> FromRequestParts<S> for ReadAll
-where
-    S: Send + Sync,
-{
+impl FromRequestParts<AppState> for ReadAll {
     type Rejection = Error;
 
-    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
+    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self> {
         let permissions = ctx!(permissions_from_bearer(parts, state).await)?;
         let org_id = permissions.org_id();
-        Ok(Self { org_id })
+        let feature_flags = match permissions.access_level {
+            AccessLevel::Organization(_) => AllowedFeatureFlags::All,
+            AccessLevel::Application(_, _) => AllowedFeatureFlags::Some(permissions.feature_flags),
+        };
+        Ok(Self {
+            org_id,
+            feature_flags,
+        })
     }
 }
 
+impl OperationInput for ReadAll {}
+
 pub struct Organization {
     pub org_id: OrganizationId,
 }
 
+impl OperationInput for Organization {}
+
 impl Permissions {
     fn check_app_is_permitted(&self, app_id: &ApplicationId) -> Result<()> {
         if let Some(ref permitted_app_id) = self.app_id() {
@@ -51,13 +60,10 @@ impl Permissions {
 }
 
 #[async_trait]
-impl<S> FromRequestParts<S> for Organization
-where
-    S: Send + Sync,
-{
+impl FromRequestParts<AppState> for Organization {
     type Rejection = Error;
 
-    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
+    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self> {
         let permissions = permissions_from_bearer(parts, state).await?;
 
         let org_id = match permissions.access_level {
@@ -74,22 +80,17 @@ pub struct Application {
 }
 
 #[async_trait]
-impl<S> FromRequestParts<S> for Application
-where
-    S: Send + Sync,
-{
+impl FromRequestParts<AppState> for Application {
     type Rejection = Error;
 
-    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
+    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self> {
         let permissions = permissions_from_bearer(parts, state).await?;
 
         let Path(ApplicationPathParams { app_id }) =
             ctx!(Path::<ApplicationPathParams>::from_request_parts(parts, state).await)?;
-        let Extension(ref db) =
-            ctx!(Extension::<DatabaseConnection>::from_request_parts(parts, state).await)?;
         let app = ctx!(
             application::Entity::secure_find_by_id_or_uid(permissions.org_id(), app_id.to_owned(),)
-                .one(db)
+                .one(&state.db)
                 .await
         )?
         .ok_or_else(|| HttpError::not_found(None, None))?;
@@ -100,28 +101,27 @@ where
     }
 }
 
+impl OperationInput for Application {}
+
 // Organization level privileges, with the requested application
 pub struct OrganizationWithApplication {
     pub app: application::Model,
 }
 
+impl OperationInput for OrganizationWithApplication {}
+
 #[async_trait]
-impl<S> FromRequestParts<S> for OrganizationWithApplication
-where
-    S: Send + Sync,
-{
+impl FromRequestParts<AppState> for OrganizationWithApplication {
     type Rejection = Error;
 
-    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
+    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self> {
         let Organization { org_id } = ctx!(Organization::from_request_parts(parts, state).await)?;
 
         let Path(ApplicationPathParams { app_id }) =
             ctx!(Path::<ApplicationPathParams>::from_request_parts(parts, state).await)?;
-        let Extension(ref db) =
-            ctx!(Extension::<DatabaseConnection>::from_request_parts(parts, state).await)?;
         let app = ctx!(
             application::Entity::secure_find_by_id_or_uid(org_id, app_id.to_owned(),)
-                .one(db)
+                .one(&state.db)
                 .await
         )?
         .ok_or_else(|| HttpError::not_found(None, None))?;
@@ -134,23 +134,24 @@ pub struct ApplicationWithMetadata {
     pub metadata: applicationmetadata::Model,
 }
 
+impl OperationInput for ApplicationWithMetadata {}
+
 #[async_trait]
-impl<S> FromRequestParts<S> for ApplicationWithMetadata
-where
-    S: Send + Sync,
-{
+impl FromRequestParts<AppState> for ApplicationWithMetadata {
     type Rejection = Error;
 
-    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
+    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self> {
         let permissions = permissions_from_bearer(parts, state).await?;
 
         let Path(ApplicationPathParams { app_id }) =
             ctx!(Path::<ApplicationPathParams>::from_request_parts(parts, state).await)?;
-        let Extension(ref db) =
-            ctx!(Extension::<DatabaseConnection>::from_request_parts(parts, state).await)?;
         let (app, metadata) = ctx!(
-            application::Model::fetch_with_metadata(db, permissions.org_id(), app_id.to_owned())
-                .await
+            application::Model::fetch_with_metadata(
+                &state.db,
+                permissions.org_id(),
+                app_id.to_owned()
+            )
+            .await
         )?
         .ok_or_else(|| HttpError::not_found(None, None))?;
 
@@ -164,3 +165,12 @@ where
 struct ApplicationPathParams {
     app_id: ApplicationIdOrUid,
 }
+
+/// Denotes what features gated by feature flags the requester has access to.
+pub enum AllowedFeatureFlags {
+    /// Requester has access to all features regardless of flags. No checking
+    /// of feature flags should be done for this request.
+    All,
+    /// Requester has access to a limited set of features. The set may be empty.
+    Some(FeatureFlagSet),
+}
diff --git a/server/svix-server/src/core/security.rs b/server/svix-server/src/core/security.rs
index 10686a77b..6a57c57c8 100644
--- a/server/svix-server/src/core/security.rs
+++ b/server/svix-server/src/core/security.rs
@@ -4,7 +4,7 @@
 use std::fmt::Debug;
 
 use axum::{
-    extract::{Extension, FromRequestParts, TypedHeader},
+    extract::{FromRequestParts, TypedHeader},
     headers::{authorization::Bearer, Authorization},
 };
 
@@ -14,12 +14,12 @@ use jwt_simple::prelude::*;
 use validator::Validate;
 
 use crate::{
-    cfg::Configuration,
     ctx,
     error::{HttpError, Result},
+    AppState,
 };
 
-use super::types::{ApplicationId, OrganizationId};
+use super::types::{ApplicationId, FeatureFlagSet, OrganizationId};
 
 /// The default org_id we use (useful for generating JWTs when testing).
 pub fn default_org_id() -> OrganizationId {
@@ -38,6 +38,7 @@ pub enum AccessLevel {
 
 pub struct Permissions {
     pub access_level: AccessLevel,
+    pub feature_flags: FeatureFlagSet,
 }
 
 impl Permissions {
@@ -60,21 +61,29 @@ impl Permissions {
 pub struct CustomClaim {
     #[serde(rename = "org", default, skip_serializing_if = "Option::is_none")]
     pub organization: Option<String>,
-}
 
-pub async fn permissions_from_bearer<S: Send + Sync>(
-    parts: &mut Parts,
-    state: &S,
-) -> Result<Permissions> {
-    let Extension(ref cfg) =
-        ctx!(Extension::<Configuration>::from_request_parts(parts, state).await)?;
+    #[serde(
+        rename = "feature_flags",
+        default,
+        skip_serializing_if = "FeatureFlagSet::is_empty"
+    )]
+    pub feature_flags: FeatureFlagSet,
+}
 
+pub async fn permissions_from_bearer(parts: &mut Parts, state: &AppState) -> Result<Permissions> {
     let TypedHeader(Authorization(bearer)) =
         ctx!(TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await)?;
 
-    let claims = parse_bearer(&cfg.jwt_secret, &bearer)
+    let claims = parse_bearer(&state.cfg.jwt_secret, &bearer)
         .ok_or_else(|| HttpError::unauthorized(None, Some("Invalid token".to_string())))?;
-    permissions_from_jwt(claims)
+    let perms = permissions_from_jwt(claims)?;
+
+    tracing::Span::current().record("org_id", perms.org_id().to_string());
+    if let Some(app_id) = perms.app_id() {
+        tracing::Span::current().record("app_id", app_id.to_string());
+    }
+
+    Ok(perms)
 }
 
 pub fn parse_bearer(key: &Keys, bearer: &Bearer) -> Option<JWTClaims<CustomClaim>> {
@@ -106,6 +115,7 @@ pub fn permissions_from_jwt(claims: JWTClaims<CustomClaim>) -> Result<Permission
 
             Ok(Permissions {
                 access_level: AccessLevel::Application(org_id, app_id),
+                feature_flags: claims.custom.feature_flags,
             })
         } else {
             Err(
@@ -125,6 +135,7 @@ pub fn permissions_from_jwt(claims: JWTClaims<CustomClaim>) -> Result<Permission
         })?;
         Ok(Permissions {
             access_level: AccessLevel::Organization(org_id),
+            feature_flags: claims.custom.feature_flags,
         })
     } else {
         Err(
@@ -138,7 +149,10 @@ const JWT_ISSUER: &str = env!("CARGO_PKG_NAME");
 
 pub fn generate_org_token(keys: &Keys, org_id: OrganizationId) -> Result<String> {
     let claims = Claims::with_custom_claims(
-        CustomClaim { organization: None },
+        CustomClaim {
+            organization: None,
+            feature_flags: Default::default(),
+        },
         Duration::from_hours(24 * 365 * 10),
     )
     .with_issuer(JWT_ISSUER)
@@ -147,10 +161,15 @@ pub fn generate_org_token(keys: &Keys, org_id: OrganizationId) -> Result<String>
 }
 
 pub fn generate_management_token(keys: &Keys) -> Result<String> {
-    let claims =
-        Claims::with_custom_claims(CustomClaim { organization: None }, Duration::from_mins(10))
-            .with_issuer(JWT_ISSUER)
-            .with_subject(management_org_id());
+    let claims = Claims::with_custom_claims(
+        CustomClaim {
+            organization: None,
+            feature_flags: Default::default(),
+        },
+        Duration::from_mins(10),
+    )
+    .with_issuer(JWT_ISSUER)
+    .with_subject(management_org_id());
     Ok(keys.key.authenticate(claims).unwrap())
 }
 
@@ -158,10 +177,12 @@ pub fn generate_app_token(
     keys: &Keys,
     org_id: OrganizationId,
     app_id: ApplicationId,
+    feature_flags: FeatureFlagSet,
 ) -> Result<String> {
     let claims = Claims::with_custom_claims(
         CustomClaim {
             organization: Some(org_id.0),
+            feature_flags,
         },
         Duration::from_hours(24 * 28),
     )
diff --git a/server/svix-server/src/core/types/metadata.rs b/server/svix-server/src/core/types/metadata.rs
index 5bab624bc..b3e9e6ae9 100644
--- a/server/svix-server/src/core/types/metadata.rs
+++ b/server/svix-server/src/core/types/metadata.rs
@@ -1,11 +1,12 @@
 use std::collections::HashMap;
 
 use crate::json_wrapper;
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
 pub const MAX_METADATA_SIZE: usize = 4096;
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default, JsonSchema)]
 pub struct Metadata(HashMap<String, String>);
 
 json_wrapper!(Metadata);
diff --git a/server/svix-server/src/core/types/mod.rs b/server/svix-server/src/core/types/mod.rs
index cb4534fc2..5446f5cc0 100644
--- a/server/svix-server/src/core/types/mod.rs
+++ b/server/svix-server/src/core/types/mod.rs
@@ -15,6 +15,7 @@ use rand::Rng;
 
 use regex::Regex;
 
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::ops::Deref;
 use svix_ksuid::*;
@@ -249,9 +250,15 @@ pub trait BaseUid: Deref<Target = String> {
 
 macro_rules! string_wrapper {
     ($name_id:ident) => {
-        #[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize)]
+        #[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
         pub struct $name_id(pub String);
 
+        string_wrapper_impl!($name_id);
+    };
+}
+
+macro_rules! string_wrapper_impl {
+    ($name_id:ident) => {
         impl Deref for $name_id {
             type Target = String;
 
@@ -374,7 +381,7 @@ macro_rules! create_all_id_types {
         }
 
         // Id or uid
-        #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+        #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
         pub struct $name_id_or_uid(pub String);
 
         impl From<$name_id_or_uid> for $name_uid {
@@ -428,7 +435,7 @@ impl Validate for EventChannel {
     }
 }
 
-#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct EventChannelSet(pub HashSet<EventChannel>);
 json_wrapper!(EventChannelSet);
 
@@ -441,7 +448,7 @@ impl Validate for EventChannelSet {
     }
 }
 
-#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
 pub struct EventTypeNameSet(pub HashSet<EventTypeName>);
 json_wrapper!(EventTypeNameSet);
 
@@ -454,7 +461,7 @@ impl Validate for EventTypeNameSet {
     }
 }
 
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, JsonSchema)]
 pub struct RetrySchedule(pub Vec<Duration>);
 json_wrapper!(RetrySchedule);
 
@@ -966,6 +973,24 @@ impl Validate for EndpointSecret {
     }
 }
 
+impl JsonSchema for EndpointSecret {
+    fn schema_name() -> String {
+        "EndpointSecret".to_string()
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        const KEY_PATTERN: &str = "^(whsec_)?[a-zA-Z0-9+/=]{32,100}$";
+        let mut schema = String::json_schema(gen);
+        if let schemars::schema::Schema::Object(ref mut obj) = schema {
+            obj.string = Some(Box::new(schemars::schema::StringValidation {
+                pattern: Some(KEY_PATTERN.to_string()),
+                ..Default::default()
+            }));
+        }
+        schema
+    }
+}
+
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub struct ExpiringSigningKey {
     #[serde(rename = "signingKey")]
@@ -1027,42 +1052,54 @@ fn validate_header_key(k: &str, errors: &mut ValidationErrors) {
     })
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default, JsonSchema)]
 pub struct EndpointHeaders(pub HashMap<String, String>);
 json_wrapper!(EndpointHeaders);
 
+const HEADER_MAX_LENGTH: usize = 4096;
+
 impl<'de> Deserialize<'de> for EndpointHeaders {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
         D: serde::Deserializer<'de>,
     {
-        HashMap::deserialize(deserializer)
-            .map(|x: HashMap<String, String>| x.into_iter().collect())
-            .map(EndpointHeaders)
+        let headers: HashMap<String, String> = HashMap::deserialize(deserializer)?;
+
+        validate_header_map(&headers).map_err(serde::de::Error::custom)?;
+
+        Ok(EndpointHeaders(headers))
     }
 }
 
-impl Validate for EndpointHeaders {
-    fn validate(&self) -> std::result::Result<(), ValidationErrors> {
-        let mut errors = ValidationErrors::new();
-        self.0.iter().for_each(|(k, v)| {
-            validate_header_key(k, &mut errors);
-            if let Err(_e) = http::header::HeaderValue::try_from(v) {
-                errors.add(
-                    ALL_ERROR,
-                    validation_error(Some("header"), Some("Invalid Header Value.")),
-                );
-            }
-        });
-        if errors.is_empty() {
-            Ok(())
-        } else {
-            Err(errors)
+fn validate_header_map(
+    headers: &HashMap<String, String>,
+) -> std::result::Result<(), ValidationErrors> {
+    let mut errors = ValidationErrors::new();
+    for (k, v) in headers {
+        validate_header_key(k, &mut errors);
+
+        if let Err(_e) = http::header::HeaderValue::try_from(v) {
+            errors.add(
+                ALL_ERROR,
+                validation_error(Some("header"), Some("Invalid Header Value.")),
+            );
+        }
+
+        if v.len() > HEADER_MAX_LENGTH {
+            errors.add(
+                ALL_ERROR,
+                validation_error(Some("header"), Some("Maximum header length is 4096 bytes")),
+            );
         }
     }
+    if errors.is_empty() {
+        Ok(())
+    } else {
+        Err(errors)
+    }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default, JsonSchema)]
 pub struct EndpointHeadersPatch(pub HashMap<String, Option<String>>);
 json_wrapper!(EndpointHeadersPatch);
 
@@ -1092,14 +1129,14 @@ impl Validate for EndpointHeadersPatch {
 }
 
 #[repr(i16)]
-#[derive(Clone, Debug, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Debug, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive, JsonSchema)]
 pub enum MessageAttemptTriggerType {
     Scheduled = 0,
     Manual = 1,
 }
 
 #[repr(i16)]
-#[derive(Clone, Debug, Copy, PartialEq, IntoPrimitive, TryFromPrimitive, Hash, Eq)]
+#[derive(Clone, Debug, Copy, PartialEq, IntoPrimitive, TryFromPrimitive, Hash, Eq, JsonSchema)]
 pub enum MessageStatus {
     Success = 0,
     Pending = 1,
@@ -1108,7 +1145,7 @@ pub enum MessageStatus {
 }
 
 #[repr(i16)]
-#[derive(Clone, Debug, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Debug, Copy, PartialEq, Eq, IntoPrimitive, TryFromPrimitive, JsonSchema)]
 pub enum StatusCodeClass {
     CodeNone = 0,
     Code1xx = 100,
@@ -1122,6 +1159,25 @@ enum_wrapper!(MessageAttemptTriggerType);
 enum_wrapper!(MessageStatus);
 enum_wrapper!(StatusCodeClass);
 
+#[derive(Clone, Debug, Hash, Eq, PartialEq, Serialize, JsonSchema)]
+pub struct FeatureFlag(pub String);
+
+string_wrapper_impl!(FeatureFlag);
+
+impl<'de> Deserialize<'de> for FeatureFlag {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        String::deserialize(deserializer).and_then(|s| {
+            validate_limited_str(&s).map_err(serde::de::Error::custom)?;
+            Ok(FeatureFlag(s))
+        })
+    }
+}
+
+pub type FeatureFlagSet = HashSet<FeatureFlag>;
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1199,29 +1255,29 @@ mod tests {
             ("also-valid".to_owned(), "true".to_owned()),
         ]);
         let endpoint_headers = EndpointHeaders(hdr_map);
-        endpoint_headers.validate().unwrap();
+        validate_header_map(&endpoint_headers.0).unwrap();
 
         let hdr_map = HashMap::from([
             ("invalid?".to_owned(), "true".to_owned()),
             ("valid".to_owned(), "true".to_owned()),
         ]);
         let endpoint_headers = EndpointHeaders(hdr_map);
-        assert!(endpoint_headers.validate().is_err());
+        assert!(validate_header_map(&endpoint_headers.0).is_err());
 
         let hdr_map = HashMap::from([
             ("invalid\0".to_owned(), "true".to_owned()),
             ("valid".to_owned(), "true".to_owned()),
         ]);
         let endpoint_headers = EndpointHeaders(hdr_map);
-        assert!(endpoint_headers.validate().is_err());
+        assert!(validate_header_map(&endpoint_headers.0).is_err());
 
         let hdr_map = HashMap::from([("User-Agent".to_string(), "true".to_owned())]);
         let endpoint_headers = EndpointHeaders(hdr_map);
-        assert!(endpoint_headers.validate().is_err());
+        assert!(validate_header_map(&endpoint_headers.0).is_err());
 
         let hdr_map = HashMap::from([("X-Amz-".to_string(), "true".to_owned())]);
         let endpoint_headers = EndpointHeaders(hdr_map);
-        assert!(endpoint_headers.validate().is_err());
+        assert!(validate_header_map(&endpoint_headers.0).is_err());
     }
 
     #[test]
diff --git a/server/svix-server/src/core/webhook_http_client.rs b/server/svix-server/src/core/webhook_http_client.rs
new file mode 100644
index 000000000..197d1d7c4
--- /dev/null
+++ b/server/svix-server/src/core/webhook_http_client.rs
@@ -0,0 +1,764 @@
+use std::{
+    collections::HashMap,
+    future::Future,
+    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
+    pin::Pin,
+    str::FromStr,
+    sync::Arc,
+    task::Poll,
+    time::{Duration, Instant},
+};
+
+use axum::headers::{authorization::Credentials, Authorization};
+use bytes::Bytes;
+use futures::{future::BoxFuture, FutureExt};
+use http::header::HeaderName;
+use http::{HeaderMap, HeaderValue, Method, Response, StatusCode, Version};
+use hyper::{
+    client::connect::{dns::Name, HttpConnector},
+    ext::HeaderCaseMap,
+    Body, Client, Uri,
+};
+use hyper_openssl::HttpsConnector;
+use ipnet::IpNet;
+use openssl::ssl::{SslConnector, SslMethod};
+use serde::Serialize;
+use thiserror::Error;
+use tokio::sync::Mutex;
+use tower::Service;
+use trust_dns_resolver::{
+    error::ResolveError, lookup_ip::LookupIpIntoIter, AsyncResolver, TokioConnection,
+    TokioConnectionProvider, TokioHandle,
+};
+
+pub type CaseSensitiveHeaderMap = HashMap<String, HeaderValue>;
+
+#[derive(Debug, Error)]
+pub enum Error {
+    #[error("failure response: {0}")]
+    FailureStatus(StatusCode),
+
+    #[error("making requests to local IP addresses is forbidden and blocked")]
+    BlockedIp,
+    #[error("error resolving name: {0}")]
+    ResolveError(#[from] ResolveError),
+
+    #[error("request timed out")]
+    TimedOut,
+
+    #[error("error forming request: {0}")]
+    InvalidHttpRequest(http::Error),
+    #[error("error making request: {0}")]
+    FailedRequest(hyper::Error),
+}
+
+pub struct WebhookClient {
+    client: Client<HttpsConnector<NonLocalConnector>, Body>,
+    whitelist_nets: Arc<Vec<IpNet>>,
+}
+
+impl WebhookClient {
+    pub fn new(
+        whitelist_nets: Option<Arc<Vec<IpNet>>>,
+        whitelist_names: Option<Arc<Vec<String>>>,
+    ) -> Self {
+        let whitelist_nets = whitelist_nets.unwrap_or_else(|| Arc::new(Vec::new()));
+        let whitelist_names = whitelist_names.unwrap_or_else(|| Arc::new(Vec::new()));
+
+        let mut connector = HttpConnector::new_with_resolver(NonLocalDnsResolver::new(
+            whitelist_nets.clone(),
+            whitelist_names,
+        ));
+        connector.enforce_http(false);
+
+        let ssl = SslConnector::builder(SslMethod::tls()).expect("SslConnector build failed");
+        let https = HttpsConnector::with_connector(NonLocalConnector { connector }, ssl)
+            .expect("HttpsConnector build failed");
+
+        let client: Client<_, hyper::Body> = Client::builder()
+            .http1_ignore_invalid_headers_in_responses(true)
+            .http1_title_case_headers(true)
+            .build(https);
+
+        Self {
+            client,
+            whitelist_nets,
+        }
+    }
+
+    pub async fn execute(&self, request: Request) -> Result<Response<Body>, Error> {
+        self.execute_inner(request, true).await
+    }
+
+    pub fn execute_inner(
+        &self,
+        request: Request,
+        retry: bool,
+    ) -> BoxFuture<Result<Response<Body>, Error>> {
+        async move {
+            let org_req = request.clone();
+            if let Some(auth) = request.uri.authority() {
+                if let Ok(ip) = auth.host().parse::<IpAddr>() {
+                    if !is_allowed(ip)
+                        && !self
+                            .whitelist_nets
+                            .iter()
+                            .any(|subnet| subnet.contains(&ip))
+                    {
+                        return Err(Error::BlockedIp);
+                    }
+                }
+            }
+
+            let mut req = if let Some(body) = request.body {
+                hyper::Request::builder()
+                    .method(request.method)
+                    .uri(request.uri)
+                    .version(request.version)
+                    .body(Body::from(body))
+                    .map_err(Error::InvalidHttpRequest)?
+            } else {
+                hyper::Request::builder()
+                    .method(request.method)
+                    .uri(request.uri)
+                    .version(request.version)
+                    .body(Body::empty())
+                    .map_err(Error::InvalidHttpRequest)?
+            };
+
+            *req.headers_mut() = request.headers;
+
+            if let Some(header_names) = request.header_names {
+                req.extensions_mut().insert(header_names);
+            }
+
+            let start = Instant::now();
+            let res = if let Some(timeout) = request.timeout {
+                match tokio::time::timeout(timeout, self.client.request(req)).await {
+                    Ok(Ok(resp)) => Ok(resp),
+                    Ok(Err(e)) => Err({
+                        if e.to_string().contains(
+                            "making requests to local IP addresses is forbidden and blocked",
+                        ) {
+                            Error::BlockedIp
+                        } else {
+                            Error::FailedRequest(e)
+                        }
+                    }),
+                    Err(_to) => Err(Error::TimedOut),
+                }
+            } else {
+                self.client.request(req).await.map_err(|e| {
+                    if e.to_string()
+                        .contains("making requests to local IP addresses is forbidden and blocked")
+                    {
+                        Error::BlockedIp
+                    } else {
+                        Error::FailedRequest(e)
+                    }
+                })
+            };
+
+            if !retry {
+                return res;
+            }
+
+            match res {
+                Err(ref e) => match e {
+                    Error::FailedRequest(e) if start.elapsed() < Duration::from_millis(1000) => {
+                        tracing::info!("Insta-retrying: {}", e);
+                        self.execute_inner(org_req, false).await
+                    }
+                    _ => res,
+                },
+                res => res,
+            }
+        }
+        .boxed()
+    }
+}
+
+#[derive(Clone)]
+pub struct Request {
+    method: Method,
+    uri: Uri,
+    headers: HeaderMap,
+    header_names: Option<HeaderCaseMap>,
+    body: Option<Vec<u8>>,
+    timeout: Option<Duration>,
+    version: Version,
+}
+
+pub struct RequestBuilder {
+    method: Option<Method>,
+    uri: Option<Uri>,
+    accept: Option<HeaderValue>,
+    user_agent: Option<HeaderValue>,
+    headers: Option<HeaderMap>,
+    header_names: Option<HeaderCaseMap>,
+    body: Option<Vec<u8>>,
+    version: Option<Version>,
+    timeout: Option<Duration>,
+    basic_auth: Option<Vec<u8>>,
+
+    // Derived from body
+    content_type: Option<HeaderValue>,
+}
+
+#[derive(Debug)]
+pub struct RequestBuildError(pub Vec<BuildError>);
+
+impl std::fmt::Display for RequestBuildError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let err_str = self.0.iter().fold(String::default(), |acc, err| {
+            if acc.is_empty() {
+                format!("Build failed: {err}")
+            } else {
+                format!("{acc}; {err}")
+            }
+        });
+        write!(f, "{err_str}")
+    }
+}
+
+#[derive(Debug, Error)]
+pub enum BuildError {
+    #[error("uri missing")]
+    UriMissing,
+    #[error("version missing")]
+    VersionMissing,
+}
+
+fn decode_or_log(s: &str) -> String {
+    urlencoding::decode(s)
+        .map(|x| x.into_owned())
+        .unwrap_or_else(|_| {
+            tracing::error!("URL decoding failed");
+            s.to_owned()
+        })
+}
+
+impl RequestBuilder {
+    pub fn new() -> Self {
+        Self {
+            method: None,
+            uri: None,
+            accept: None,
+            user_agent: None,
+            headers: None,
+            header_names: None,
+            body: None,
+            version: None,
+            timeout: None,
+            content_type: None,
+            basic_auth: None,
+        }
+    }
+
+    pub fn method(mut self, method: Method) -> Self {
+        self.method = Some(method);
+        self
+    }
+
+    pub fn uri(mut self, uri: url::Url) -> Self {
+        let basic_auth = if uri.password().is_some() || !uri.username().is_empty() {
+            let username = decode_or_log(uri.username());
+            let password = uri.password().map(decode_or_log).unwrap_or_default();
+
+            Some(
+                Authorization::basic(&username, &password)
+                    .0
+                    .encode()
+                    .as_bytes()
+                    .to_vec(),
+            )
+        } else {
+            None
+        };
+        self.basic_auth = basic_auth;
+
+        let uri =
+            Uri::from_str(uri.as_str()).expect("If it's a valid url::Url, it's also a valid Uri");
+        self.uri = Some(uri);
+        self
+    }
+
+    pub fn uri_str(self, uri: &str) -> Result<Self, url::ParseError> {
+        let uri = url::Url::from_str(uri)?;
+        Ok(self.uri(uri))
+    }
+
+    fn build_headers(
+        headers: CaseSensitiveHeaderMap,
+    ) -> (hyper::HeaderMap, hyper::ext::HeaderCaseMap) {
+        let mut hdr_map = hyper::HeaderMap::with_capacity(headers.len());
+        let mut case_sensitive_hdrs: hyper::HeaderMap<Bytes> =
+            hyper::HeaderMap::with_capacity(headers.len());
+        for (k, v) in headers.into_iter() {
+            match HeaderName::from_str(&k) {
+                Ok(key) => {
+                    hdr_map.insert(key.clone(), v);
+                    case_sensitive_hdrs.insert(key, Bytes::copy_from_slice(k.as_bytes()));
+                }
+                Err(e) => {
+                    tracing::error!("Failured to parse header {} {}", k, e);
+                }
+            }
+        }
+        (hdr_map, case_sensitive_hdrs.into())
+    }
+
+    pub fn headers(mut self, headers: CaseSensitiveHeaderMap) -> Self {
+        let (hdrs, case_map) = Self::build_headers(headers);
+        self.headers = Some(hdrs);
+        self.header_names = Some(case_map);
+        self
+    }
+
+    pub fn body(mut self, body: Vec<u8>, content_type: HeaderValue) -> Self {
+        self.body = Some(body);
+        self.content_type = Some(content_type);
+        self
+    }
+
+    pub fn json_body<T: Serialize>(self, body: T) -> Result<Self, serde_json::Error> {
+        let body = serde_json::to_vec(&body)?;
+        Ok(self.body(body, HeaderValue::from_static("application/json")))
+    }
+
+    pub fn version(mut self, version: Version) -> Self {
+        self.version = Some(version);
+        self
+    }
+
+    pub fn timeout(mut self, timeout: Duration) -> Self {
+        self.timeout = Some(timeout);
+        self
+    }
+
+    pub fn user_agent(mut self, user_agent: HeaderValue) -> Self {
+        self.user_agent = Some(user_agent);
+        self
+    }
+}
+
+impl Default for RequestBuilder {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl RequestBuilder {
+    fn validate(&self) -> Result<(), RequestBuildError> {
+        let mut errs: Vec<BuildError> = Vec::new();
+        if self.uri.is_none() {
+            errs.push(BuildError::UriMissing);
+        }
+        if self.version.is_none() {
+            errs.push(BuildError::VersionMissing);
+        }
+
+        if !errs.is_empty() {
+            Err(RequestBuildError(errs))
+        } else {
+            Ok(())
+        }
+    }
+
+    pub fn build(self) -> Result<Request, RequestBuildError> {
+        self.validate()?;
+
+        let custom_headers = self.headers.unwrap_or_default();
+
+        let uri = self.uri.unwrap();
+        let authority = uri.authority().expect("Missing authority");
+        let host = match authority.port() {
+            Some(port) => format!("{}:{}", authority.host(), port),
+            None => authority.host().to_string(),
+        };
+
+        let mut headers = HeaderMap::with_capacity(3 + custom_headers.len());
+
+        // Ensure that host header is first -- even though this is technically
+        // not required by HTTP spec, some clients fail if it's not first:
+        headers.insert(http::header::HOST, HeaderValue::from_str(&host).unwrap());
+        headers.insert(
+            http::header::ACCEPT,
+            self.accept.unwrap_or(HeaderValue::from_static("*/*")),
+        );
+        headers.insert(
+            http::header::CONTENT_TYPE,
+            self.content_type
+                .unwrap_or(HeaderValue::from_static("application/json")),
+        );
+
+        headers.extend(custom_headers.into_iter());
+
+        if let Some(user_agent) = self.user_agent {
+            headers.insert(http::header::USER_AGENT, user_agent);
+        }
+
+        if let Some(auth_header) = self.basic_auth {
+            if !headers.contains_key(http::header::AUTHORIZATION) {
+                headers.insert(
+                    http::header::AUTHORIZATION,
+                    HeaderValue::from_bytes(&auth_header).unwrap(),
+                );
+            }
+        }
+
+        Ok(Request {
+            method: self.method.unwrap_or(Method::POST),
+            uri,
+            headers,
+            header_names: self.header_names,
+            body: self.body,
+            timeout: self.timeout,
+            version: self.version.unwrap(),
+        })
+    }
+}
+
+#[derive(Clone, Debug)]
+struct NonLocalConnector {
+    connector: HttpConnector<NonLocalDnsResolver>,
+}
+
+impl Service<Uri> for NonLocalConnector {
+    type Response = <hyper::client::HttpConnector as Service<Uri>>::Response;
+    type Error = <hyper::client::HttpConnector as Service<Uri>>::Error;
+
+    type Future = <hyper::client::HttpConnector<NonLocalDnsResolver> as Service<Uri>>::Future;
+
+    fn poll_ready(
+        &mut self,
+        cx: &mut std::task::Context<'_>,
+    ) -> std::task::Poll<Result<(), Self::Error>> {
+        self.connector.poll_ready(cx)
+    }
+
+    fn call(&mut self, req: Uri) -> Self::Future {
+        self.connector.call(req)
+    }
+}
+
+#[derive(Clone, Debug)]
+struct NonLocalDnsResolver {
+    state: Arc<Mutex<DnsState>>,
+    whitelist_nets: Arc<Vec<IpNet>>,
+    whitelist_names: Arc<Vec<String>>,
+}
+
+#[derive(Clone, Debug)]
+enum DnsState {
+    Init,
+    Ready(Arc<AsyncResolver<TokioConnection, TokioConnectionProvider>>),
+}
+
+impl NonLocalDnsResolver {
+    pub fn new(whitelist_nets: Arc<Vec<IpNet>>, whitelist_names: Arc<Vec<String>>) -> Self {
+        NonLocalDnsResolver {
+            state: Arc::new(Mutex::new(DnsState::Init)),
+            whitelist_nets,
+            whitelist_names,
+        }
+    }
+}
+
+impl Service<Name> for NonLocalDnsResolver {
+    type Response = SocketAddrs;
+    type Error = Error;
+    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
+
+    fn poll_ready(&mut self, _cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
+        Poll::Ready(Ok(()))
+    }
+
+    fn call(&mut self, name: Name) -> Self::Future {
+        let resolver = self.clone();
+        let whitelist_nets = self.whitelist_nets.clone();
+        let whitelist_names = self.whitelist_names.clone();
+
+        Box::pin(async move {
+            let mut lock = resolver.state.lock().await;
+
+            let resolver = match &*lock {
+                DnsState::Init => {
+                    let resolver = new_resolver().await?;
+                    *lock = DnsState::Ready(resolver.clone());
+                    resolver
+                }
+
+                DnsState::Ready(resolver) => resolver.clone(),
+            };
+
+            drop(lock);
+
+            let whitelisted_name = whitelist_names
+                .iter()
+                .any(|whitelisted| whitelisted == name.as_str());
+
+            let lookup = resolver.lookup_ip(name.as_str()).await?;
+
+            if lookup.iter().all(|ip| {
+                !is_allowed(ip)
+                    && !whitelist_nets.iter().any(|subnet| subnet.contains(&ip))
+                    && !whitelisted_name
+            }) {
+                Err(Error::BlockedIp)
+            } else {
+                Ok(SocketAddrs {
+                    iter: lookup.into_iter(),
+                    whitelist_nets,
+                    whitelisted_name,
+                })
+            }
+        })
+    }
+}
+
+pub struct SocketAddrs {
+    iter: LookupIpIntoIter,
+    whitelist_nets: Arc<Vec<IpNet>>,
+    whitelisted_name: bool,
+}
+
+impl Iterator for SocketAddrs {
+    type Item = SocketAddr;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            match self.iter.next() {
+                Some(ip_addr) => {
+                    if is_allowed(ip_addr)
+                        || self
+                            .whitelist_nets
+                            .iter()
+                            .any(|subnet| subnet.contains(&ip_addr))
+                        || self.whitelisted_name
+                    {
+                        return Some(SocketAddr::from((ip_addr, 0)));
+                    }
+                }
+
+                None => return None,
+            }
+        }
+    }
+}
+
+async fn new_resolver(
+) -> Result<Arc<AsyncResolver<TokioConnection, TokioConnectionProvider>>, ResolveError> {
+    Ok(Arc::new(AsyncResolver::from_system_conf(TokioHandle)?))
+}
+
+fn is_allowed(addr: IpAddr) -> bool {
+    match addr {
+        IpAddr::V4(addr) => {
+            !addr.is_private()
+                && !addr.is_loopback()
+                && !addr.is_link_local()
+                && !addr.is_broadcast()
+                && !addr.is_documentation()
+                && !is_shared(addr)
+                && !is_reserved(addr)
+                && !is_benchmarking(addr)
+                && !starts_with_zero(addr)
+        }
+        IpAddr::V6(addr) => {
+            !addr.is_multicast()
+                && !addr.is_loopback()
+                && !is_unicast_link_local(addr)
+                && !is_unique_local(addr)
+                && !addr.is_unspecified()
+                && !is_documentation_v6(addr)
+        }
+    }
+}
+
+/// Util functions copied from the unstable standard library near identically
+fn is_shared(addr: Ipv4Addr) -> bool {
+    addr.octets()[0] == 100 && (addr.octets()[1] & 0b1100_0000 == 0b0100_0000)
+}
+
+fn is_reserved(addr: Ipv4Addr) -> bool {
+    (addr.octets()[0] == 192 && addr.octets()[1] == 0 && addr.octets()[2] == 0)
+        || (addr.octets()[0] & 240 == 240 && !addr.is_broadcast())
+}
+
+fn is_benchmarking(addr: Ipv4Addr) -> bool {
+    addr.octets()[0] == 198 && (addr.octets()[1] & 0xfe) == 18
+}
+
+fn starts_with_zero(addr: Ipv4Addr) -> bool {
+    addr.octets()[0] == 0
+}
+
+fn is_unicast_link_local(addr: Ipv6Addr) -> bool {
+    (addr.segments()[0] & 0xffc0) == 0xfe80
+}
+
+fn is_unique_local(addr: Ipv6Addr) -> bool {
+    (addr.segments()[0] & 0xfe00) == 0xfc00
+}
+
+fn is_documentation_v6(addr: Ipv6Addr) -> bool {
+    (addr.segments()[0] == 0x2001) && (addr.segments()[1] == 0xdb8)
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{net::IpAddr, str::FromStr};
+
+    use super::*;
+
+    #[test]
+    fn is_allowed_test() {
+        // Copied shamelessly from the standard library `is_global` docs
+        assert!(!is_allowed(IpAddr::from([10, 254, 0, 0])));
+        assert!(!is_allowed(IpAddr::from([192, 168, 10, 65])));
+        assert!(!is_allowed(IpAddr::from([172, 16, 10, 65])));
+        assert!(!is_allowed(IpAddr::from([0, 1, 2, 3])));
+        assert!(!is_allowed(IpAddr::from([0, 0, 0, 0])));
+        assert!(!is_allowed(IpAddr::from([127, 0, 0, 1])));
+        assert!(!is_allowed(IpAddr::from([169, 254, 45, 1])));
+        assert!(!is_allowed(IpAddr::from([255, 255, 255, 255])));
+        assert!(!is_allowed(IpAddr::from([192, 0, 2, 255])));
+        assert!(!is_allowed(IpAddr::from([198, 51, 100, 65])));
+        assert!(!is_allowed(IpAddr::from([203, 0, 113, 6])));
+        assert!(!is_allowed(IpAddr::from([100, 100, 0, 0])));
+        assert!(!is_allowed(IpAddr::from([192, 0, 0, 0])));
+        assert!(!is_allowed(IpAddr::from([192, 0, 0, 255])));
+        assert!(!is_allowed(IpAddr::from([250, 10, 20, 30])));
+        assert!(!is_allowed(IpAddr::from([198, 18, 0, 0])));
+
+        assert!(is_allowed(IpAddr::from([1, 1, 1, 1])));
+
+        assert!(!is_allowed(IpAddr::from([0, 0, 0, 0, 0, 0, 0, 0x1])));
+
+        assert!(is_allowed(IpAddr::from([0, 0, 0, 0xffff, 0, 0, 0, 0x1])));
+    }
+
+    #[test]
+    fn test_builder() {
+        match RequestBuilder::new().build() {
+            Err(e) => assert_eq!("Build failed: uri missing; version missing", e.to_string()),
+            Ok(_) => panic!(),
+        }
+
+        assert!(RequestBuilder::new()
+            .version(Version::HTTP_11)
+            .build()
+            .is_err());
+
+        assert!(RequestBuilder::new()
+            .uri(url::Url::from_str("http://127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .is_ok());
+    }
+
+    #[test]
+    fn test_header_casings() {
+        let hdrs = CaseSensitiveHeaderMap::from([(
+            "tEsT-header-1".to_owned(),
+            HeaderValue::from_static("value"),
+        )]);
+
+        let req = RequestBuilder::new()
+            .uri(url::Url::from_str("http://127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .headers(hdrs)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req.header_names
+                .unwrap()
+                .get("test-header-1".parse().unwrap())
+                .unwrap(),
+            "tEsT-header-1".as_bytes()
+        );
+        assert_eq!(
+            req.headers.get("test-header-1").unwrap(),
+            HeaderValue::from_static("value")
+        );
+    }
+
+    #[test]
+    fn test_url_basic_auth() {
+        let req = RequestBuilder::new()
+            .uri(url::Url::from_str("http://test:123@127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req.headers.get("authorization").unwrap(),
+            "Basic dGVzdDoxMjM=".as_bytes()
+        );
+
+        let req_user_only = RequestBuilder::new()
+            .uri(url::Url::from_str("http://test:@127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req_user_only.headers.get("authorization").unwrap(),
+            "Basic dGVzdDo=".as_bytes()
+        );
+
+        let req_pass_only = RequestBuilder::new()
+            .uri(url::Url::from_str("http://:123@127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req_pass_only.headers.get("authorization").unwrap(),
+            "Basic OjEyMw==".as_bytes()
+        );
+
+        let req_no_basic_auth = RequestBuilder::new()
+            .uri(url::Url::from_str("http://127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert!(req_no_basic_auth.headers.get("authorization").is_none());
+
+        let req_special_chars = RequestBuilder::new()
+            .uri(url::Url::from_str("http://test==:123==@127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req_special_chars.headers.get("authorization").unwrap(),
+            "Basic dGVzdD09OjEyMz09".as_bytes()
+        );
+    }
+
+    #[test]
+    fn test_host_header() {
+        let req = RequestBuilder::new()
+            .uri(url::Url::from_str("http://127.0.0.1/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(req.headers.get("host").unwrap(), "127.0.0.1".as_bytes());
+
+        let req_with_port = RequestBuilder::new()
+            .uri(url::Url::from_str("http://127.0.0.1:8000/").unwrap())
+            .version(Version::HTTP_11)
+            .build()
+            .unwrap();
+
+        assert_eq!(
+            req_with_port.headers.get("host").unwrap(),
+            "127.0.0.1:8000".as_bytes()
+        );
+    }
+}
diff --git a/server/svix-server/src/db/mod.rs b/server/svix-server/src/db/mod.rs
index dcb8126d5..06c3fd571 100644
--- a/server/svix-server/src/db/mod.rs
+++ b/server/svix-server/src/db/mod.rs
@@ -15,7 +15,6 @@ use models::{application, endpoint, eventtype, message, messageattempt, messaged
 static MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!();
 
 async fn connect(dsn: &str, max_pool_size: u16) -> sqlx::Pool<sqlx::Postgres> {
-    tracing::debug!("DB: Initializing pool");
     if DbBackend::Postgres.is_prefix_of(dsn) {
         PgPoolOptions::new()
             .max_connections(max_pool_size.into())
diff --git a/server/svix-server/src/db/models/eventtype.rs b/server/svix-server/src/db/models/eventtype.rs
index ac1a410e8..6894e5b5d 100644
--- a/server/svix-server/src/db/models/eventtype.rs
+++ b/server/svix-server/src/db/models/eventtype.rs
@@ -2,13 +2,17 @@
 // SPDX-License-Identifier: MIT
 
 use crate::{
-    core::types::{BaseId, EventTypeId, EventTypeName, OrganizationId, RetrySchedule},
+    core::types::{
+        BaseId, EventTypeId, EventTypeName, FeatureFlag, FeatureFlagSet, OrganizationId,
+        RetrySchedule,
+    },
     json_wrapper,
 };
 use std::collections::HashMap;
 
 use chrono::Utc;
 use jsonschema::{Draft, JSONSchema};
+use schemars::JsonSchema;
 use sea_orm::entity::prelude::*;
 use sea_orm::ActiveValue::Set;
 use serde::{Deserialize, Serialize};
@@ -26,6 +30,7 @@ pub struct Model {
     pub schemas: Option<Schema>,
     pub name: EventTypeName,
     pub retry_schedule: Option<RetrySchedule>,
+    pub feature_flag: Option<FeatureFlag>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter)]
@@ -63,9 +68,17 @@ impl Entity {
     pub fn secure_find_by_name(org_id: OrganizationId, name: EventTypeName) -> Select<Entity> {
         Self::secure_find(org_id).filter(Column::Name.eq(name))
     }
+
+    pub fn filter_feature_flags(query: Select<Self>, flags: FeatureFlagSet) -> Select<Self> {
+        query.filter(
+            sea_orm::Condition::any()
+                .add(Column::FeatureFlag.is_in(flags.into_iter()))
+                .add(Column::FeatureFlag.is_null()),
+        )
+    }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Default, JsonSchema)]
 pub struct Schema(HashMap<String, Json>);
 json_wrapper!(Schema);
 
diff --git a/server/svix-server/src/error.rs b/server/svix-server/src/error.rs
index d51079cf4..5636bebb5 100644
--- a/server/svix-server/src/error.rs
+++ b/server/svix-server/src/error.rs
@@ -4,6 +4,7 @@
 use std::error;
 use std::fmt;
 
+use aide::OperationOutput;
 use axum::extract::rejection::ExtensionRejection;
 use axum::extract::rejection::PathRejection;
 use axum::extract::rejection::TypedHeaderRejection;
@@ -14,6 +15,7 @@ use axum::response::Response;
 use axum::Json;
 use axum::TypedHeader;
 use hyper::StatusCode;
+use schemars::JsonSchema;
 use sea_orm::DbErr;
 use sea_orm::RuntimeErr;
 use sea_orm::TransactionError;
@@ -21,6 +23,8 @@ use serde::Serialize;
 use serde_json::json;
 use sqlx::Error as SqlxError;
 
+use crate::core::webhook_http_client;
+
 /// A short-hand version of a [std::result::Result] that always returns an Svix [Error].
 pub type Result<T> = std::result::Result<T, Error>;
 
@@ -109,6 +113,10 @@ impl IntoResponse for Error {
     }
 }
 
+impl OperationOutput for Error {
+    type Inner = Self;
+}
+
 /// Returns a [&'static str] of the file.rs:<line_number>.
 #[macro_export]
 macro_rules! location {
@@ -143,6 +151,13 @@ macro_rules! err_queue {
     };
 }
 
+#[macro_export]
+macro_rules! err_cache {
+    ($s:expr) => {
+        $crate::error::Error::cache($s, $crate::location!())
+    };
+}
+
 #[macro_export]
 macro_rules! err_validation {
     ($s:expr) => {
@@ -265,6 +280,8 @@ pub enum ErrorType {
     Http(HttpError),
     /// Cache error
     Cache(String),
+    /// Timeout error
+    Timeout(String),
 }
 
 impl fmt::Display for ErrorType {
@@ -276,6 +293,7 @@ impl fmt::Display for ErrorType {
             Self::Validation(s) => s.fmt(f),
             Self::Http(s) => s.fmt(f),
             Self::Cache(s) => s.fmt(f),
+            Self::Timeout(s) => s.fmt(f),
         }
     }
 }
@@ -293,7 +311,7 @@ pub enum HttpErrorBody {
     Validation { detail: Vec<ValidationErrorItem> },
 }
 
-#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
+#[derive(Debug, Clone, Serialize, PartialEq, Eq, JsonSchema)]
 /// Validation errors have their own schema to provide context for invalid requests eg. mismatched
 /// types and out of bounds values. There may be any number of these per 422 UNPROCESSABLE ENTITY
 /// error.
@@ -313,7 +331,7 @@ pub struct ValidationErrorItem {
 
 #[derive(Debug, Clone)]
 pub struct HttpError {
-    status: StatusCode,
+    pub status: StatusCode,
     body: HttpErrorBody,
 }
 
@@ -422,3 +440,18 @@ impl IntoResponse for HttpError {
         (self.status, Json(self.body)).into_response()
     }
 }
+
+impl From<ErrorType> for Error {
+    fn from(typ: ErrorType) -> Self {
+        Self { trace: vec![], typ }
+    }
+}
+
+impl From<crate::core::webhook_http_client::Error> for Error {
+    fn from(err: webhook_http_client::Error) -> Error {
+        match err {
+            webhook_http_client::Error::TimedOut => ErrorType::Timeout(err.to_string()).into(),
+            _ => err_generic!(err.to_string()),
+        }
+    }
+}
diff --git a/server/svix-server/src/lib.rs b/server/svix-server/src/lib.rs
index 54ed64795..f542959b9 100644
--- a/server/svix-server/src/lib.rs
+++ b/server/svix-server/src/lib.rs
@@ -4,12 +4,18 @@
 #![warn(clippy::all)]
 #![forbid(unsafe_code)]
 
-use axum::{extract::Extension, Router};
+use aide::{
+    axum::ApiRouter,
+    openapi::{self, OpenApi},
+};
 
+use crate::core::cache::Cache;
 use cfg::ConfigurationInner;
 use lazy_static::lazy_static;
 use opentelemetry::runtime::Tokio;
 use opentelemetry_otlp::WithExportConfig;
+use queue::TaskQueueProducer;
+use sea_orm::DatabaseConnection;
 use std::{
     net::TcpListener,
     sync::atomic::{AtomicBool, Ordering},
@@ -17,7 +23,6 @@ use std::{
 };
 use tower::ServiceBuilder;
 use tower_http::cors::{AllowHeaders, Any, CorsLayer};
-use tower_http::trace::TraceLayer;
 use tracing_subscriber::{prelude::*, util::SubscriberInitExt};
 
 use crate::{
@@ -25,12 +30,11 @@ use crate::{
     core::{
         cache,
         idempotency::IdempotencyService,
-        operational_webhooks::OperationalWebhookSenderInner,
-        otel_spans::{AxumOtelOnFailure, AxumOtelOnResponse, AxumOtelSpanCreator},
+        operational_webhooks::{OperationalWebhookSender, OperationalWebhookSenderInner},
     },
     db::init_db,
     expired_message_cleaner::expired_message_cleaner_loop,
-    worker::worker_loop,
+    worker::queue_handler,
 };
 
 pub mod cfg;
@@ -81,6 +85,120 @@ pub async fn run(cfg: Configuration, listener: Option<TcpListener>) {
     run_with_prefix(None, cfg, listener).await
 }
 
+pub fn initialize_openapi() -> OpenApi {
+    aide::gen::on_error(|error| {
+        tracing::error!("Aide generation error: {error}");
+    });
+    // Extract schemas to `#/components/schemas/` instead of using inline schemas.
+    aide::gen::extract_schemas(true);
+    // Have aide attempt to infer the `Content-Type` of responses based on the
+    // handlers' return types.
+    aide::gen::infer_responses(true);
+
+    aide::gen::in_context(|ctx| {
+        ctx.schema = schemars::gen::SchemaGenerator::new(
+            schemars::gen::SchemaSettings::draft07().with(|s| {
+                // HACK: These are set by the `extract_schemas` call above, but
+                // reinitializing the schema generator here means we are
+                // overwriting it. Hopefully, in the future we can set schema
+                // generator options without overwriting settings set by aide
+                // internally.
+                s.inline_subschemas = false;
+                s.definitions_path = "#/components/schemas/".into();
+
+                // Schemars generates an `anyOf` for `Option<T>` by default. This changes that
+                // to instead simply mark them as nullable.
+                s.option_nullable = true;
+                s.option_add_null_type = false;
+            }),
+        );
+    });
+
+    let tag_groups = serde_json::json![[
+        {
+            "name": "General",
+            "tags": ["Application", "Event Type"]
+        },
+        {
+            "name": "Application specific",
+            "tags": ["Authentication", "Endpoint", "Message", "Message Attempt", "Integration"]
+        },
+        {
+            "name": "Utility",
+            "tags": ["Health"]
+        },
+        {
+            "name": "Webhooks",
+            "tags": ["Webhooks"]
+        }
+    ]];
+
+    OpenApi {
+        info: openapi::Info {
+            title: "Svix API".to_owned(),
+            version: "1.4".to_owned(),
+            extensions: indexmap::indexmap! {
+                "x-logo".to_string() => serde_json::json!({
+                    "url": "https://www.svix.com/static/img/brand-padded.svg",
+                    "altText": "Svix Logo",
+                }),
+            },
+            ..Default::default()
+        },
+        tags: vec![
+            openapi::Tag {
+                name: "Application".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Message".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Message Attempt".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Endpoint".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Integration".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Event Type".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Authentication".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Health".to_owned(),
+                ..openapi::Tag::default()
+            },
+            openapi::Tag {
+                name: "Webhooks".to_owned(),
+                ..openapi::Tag::default()
+            },
+        ],
+        extensions: indexmap::indexmap! {
+            "x-tagGroups".to_owned() => tag_groups,
+        },
+        ..Default::default()
+    }
+}
+
+#[derive(Clone)]
+pub struct AppState {
+    db: DatabaseConnection,
+    queue_tx: TaskQueueProducer,
+    cfg: Configuration,
+    cache: Cache,
+    op_webhooks: OperationalWebhookSender,
+}
+
 // Made public for the purpose of E2E testing in which a queue prefix is necessary to avoid tests
 // consuming from each others' queues
 pub async fn run_with_prefix(
@@ -88,9 +206,11 @@ pub async fn run_with_prefix(
     cfg: Configuration,
     listener: Option<TcpListener>,
 ) {
+    tracing::debug!("DB: Initializing pool");
     let pool = init_db(&cfg).await;
+    tracing::debug!("DB: Started");
 
-    tracing::debug!("Cache type: {:?}", cfg.cache_type);
+    tracing::debug!("Cache: Initializing {:?}", cfg.cache_type);
     let cache = match cfg.cache_backend() {
         CacheBackend::None => cache::none::new(),
         CacheBackend::Memory => cache::memory::new(),
@@ -103,20 +223,40 @@ pub async fn run_with_prefix(
             cache::redis::new(mgr)
         }
     };
+    tracing::debug!("Cache: Started");
 
-    tracing::debug!("Queue type: {:?}", cfg.queue_type);
+    tracing::debug!("Queue: Initializing {:?}", cfg.queue_type);
     let (queue_tx, queue_rx) = queue::new_pair(&cfg, prefix.as_deref()).await;
+    tracing::debug!("Queue: Started");
 
     let op_webhook_sender = OperationalWebhookSenderInner::new(
         cfg.jwt_secret.clone(),
         cfg.operational_webhook_address.clone(),
     );
 
+    // OpenAPI/aide must be initialized before any routers are constructed
+    // because its initialization sets generation-global settings which are
+    // needed at router-construction time.
+    let mut openapi = initialize_openapi();
+
     let svc_cache = cache.clone();
     // build our application with a route
-    let app = Router::new()
-        .nest("/api/v1", v1::router())
-        .merge(docs::router())
+    let app_state = AppState {
+        db: pool.clone(),
+        queue_tx: queue_tx.clone(),
+        cfg: cfg.clone(),
+        cache: cache.clone(),
+        op_webhooks: op_webhook_sender.clone(),
+    };
+    let v1_router = v1::router().with_state::<()>(app_state);
+
+    // Initialize all routes which need to be part of OpenAPI first.
+    let app = ApiRouter::new()
+        .nest_api_service("/api/v1", v1_router)
+        .finish_api(&mut openapi);
+    let docs_router = docs::router(openapi);
+    let app = app
+        .merge(docs_router)
         .layer(
             ServiceBuilder::new().layer_fn(move |service| IdempotencyService {
                 cache: svc_cache.clone(),
@@ -129,18 +269,7 @@ pub async fn run_with_prefix(
                 .allow_methods(Any)
                 .allow_headers(AllowHeaders::mirror_request())
                 .max_age(Duration::from_secs(600)),
-        )
-        .layer(
-            TraceLayer::new_for_http()
-                .make_span_with(AxumOtelSpanCreator)
-                .on_response(AxumOtelOnResponse)
-                .on_failure(AxumOtelOnFailure),
-        )
-        .layer(Extension(pool.clone()))
-        .layer(Extension(queue_tx.clone()))
-        .layer(Extension(cfg.clone()))
-        .layer(Extension(cache.clone()))
-        .layer(Extension(op_webhook_sender.clone()));
+        );
 
     let with_api = cfg.api_enabled;
     let with_worker = cfg.worker_enabled;
@@ -172,11 +301,11 @@ pub async fn run_with_prefix(
         },
         async {
             if with_worker {
-                tracing::debug!("Worker: Initializing");
-                worker_loop(
+                tracing::debug!("Worker: Started");
+                queue_handler(
                     &cfg,
-                    &pool,
                     cache.clone(),
+                    pool.clone(),
                     queue_tx,
                     queue_rx,
                     op_webhook_sender,
@@ -189,7 +318,7 @@ pub async fn run_with_prefix(
         },
         async {
             if with_worker {
-                tracing::debug!("Expired message cleaner: Initializing");
+                tracing::debug!("Expired message cleaner: Started");
                 expired_message_cleaner_loop(&pool).await
             } else {
                 tracing::debug!("Expired message cleaner: off");
@@ -277,17 +406,21 @@ pub fn setup_tracing(cfg: &ConfigurationInner) {
 }
 
 mod docs {
+    use aide::{axum::ApiRouter, openapi::OpenApi};
     use axum::{
         response::{Html, IntoResponse, Redirect},
         routing::get,
-        Json, Router,
+        Json,
     };
 
-    pub fn router() -> Router {
-        Router::new()
+    // TODO: switch to generated docs instead of hardcoded JSON once generated
+    // is comparable/better than hardcoded one.
+    pub fn router(_docs: OpenApi) -> ApiRouter {
+        ApiRouter::new()
             .route("/", get(|| async { Redirect::temporary("/docs") }))
             .route("/docs", get(get_docs))
             .route("/api/v1/openapi.json", get(get_openapi_json))
+            .with_state(_docs)
     }
 
     async fn get_docs() -> Html<&'static str> {
diff --git a/server/svix-server/src/main.rs b/server/svix-server/src/main.rs
index 2399ec85f..a98fdc7e7 100644
--- a/server/svix-server/src/main.rs
+++ b/server/svix-server/src/main.rs
@@ -135,8 +135,9 @@ async fn main() {
     }
 
     if args.run_migrations {
+        tracing::debug!("Migrations: Running");
         db::run_migrations(&cfg).await;
-        tracing::debug!("Migrations: success");
+        tracing::debug!("Migrations: Success");
     }
 
     match args.command {
diff --git a/server/svix-server/src/queue/mod.rs b/server/svix-server/src/queue/mod.rs
index 21ca7ff44..152ac62b5 100644
--- a/server/svix-server/src/queue/mod.rs
+++ b/server/svix-server/src/queue/mod.rs
@@ -3,6 +3,7 @@ use std::{sync::Arc, time::Duration};
 use axum::async_trait;
 use chrono::{DateTime, Utc};
 use serde::{Deserialize, Serialize};
+use strum::Display;
 use svix_ksuid::*;
 
 use crate::{
@@ -93,7 +94,7 @@ impl MessageTaskBatch {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
 #[serde(rename_all = "camelCase")]
 #[serde(tag = "type")]
 pub enum QueueTask {
diff --git a/server/svix-server/src/v1/endpoints/application.rs b/server/svix-server/src/v1/endpoints/application.rs
index fed8a0128..74fea25eb 100644
--- a/server/svix-server/src/v1/endpoints/application.rs
+++ b/server/svix-server/src/v1/endpoints/application.rs
@@ -11,6 +11,7 @@ use crate::{
     error::{HttpError, Result},
     transaction,
     v1::utils::{
+        openapi_tag,
         patch::{
             patch_field_non_nullable, patch_field_nullable, UnrequiredField,
             UnrequiredNullableField,
@@ -19,21 +20,26 @@ use crate::{
         validation_error, EmptyResponse, ListResponse, ModelIn, ModelOut, Pagination,
         PaginationLimit, ValidatedJson, ValidatedQuery,
     },
+    AppState,
 };
-use axum::{
-    extract::{Extension, Path},
+use aide::axum::{
     routing::{get, post},
-    Json, Router,
+    ApiRouter,
+};
+use axum::{
+    extract::{Path, State},
+    Json,
 };
 use chrono::{DateTime, Utc};
 use hyper::StatusCode;
+use schemars::JsonSchema;
+use sea_orm::ActiveModelTrait;
 use sea_orm::ActiveValue::Set;
-use sea_orm::{ActiveModelTrait, DatabaseConnection};
 use serde::{Deserialize, Serialize};
 use svix_server_derive::ModelOut;
 use validator::{Validate, ValidationError};
 
-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Validate)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Validate, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct ApplicationIn {
     #[validate(
@@ -73,7 +79,7 @@ impl ModelIn for ApplicationIn {
     }
 }
 
-#[derive(Deserialize, Serialize, Validate)]
+#[derive(Deserialize, Serialize, Validate, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct ApplicationPatch {
     #[serde(default, skip_serializing_if = "UnrequiredField::is_absent")]
@@ -153,7 +159,7 @@ fn validate_rate_limit_patch(
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct ApplicationOut {
     // FIXME: Do we want to use serde(flatten) or just duplicate the keys?
@@ -184,7 +190,7 @@ impl From<(application::Model, applicationmetadata::Model)> for ApplicationOut {
 }
 
 async fn list_applications(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     pagination: ValidatedQuery<Pagination<ApplicationId>>,
     permissions::Organization { org_id }: permissions::Organization,
 ) -> Result<Json<ListResponse<ApplicationOut>>> {
@@ -206,14 +212,14 @@ fn default_as_false() -> bool {
     false
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct CreateApplicationQuery {
     #[serde(default = "default_as_false")]
     get_if_exists: bool,
 }
 
 async fn create_application(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     query: ValidatedQuery<CreateApplicationQuery>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<ApplicationIn>,
@@ -256,7 +262,7 @@ async fn get_application(
 }
 
 async fn update_application(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(app_id): Path<ApplicationIdOrUid>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<ApplicationIn>,
@@ -294,7 +300,7 @@ async fn update_application(
 }
 
 async fn patch_application(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     permissions::OrganizationWithApplication { app }: permissions::OrganizationWithApplication,
     ValidatedJson(data): ValidatedJson<ApplicationPatch>,
 ) -> Result<Json<ApplicationOut>> {
@@ -315,7 +321,7 @@ async fn patch_application(
 }
 
 async fn delete_application(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     permissions::OrganizationWithApplication { app }: permissions::OrganizationWithApplication,
 ) -> Result<(StatusCode, Json<EmptyResponse>)> {
     let mut app: application::ActiveModel = app.into();
@@ -325,15 +331,20 @@ async fn delete_application(
     Ok((StatusCode::NO_CONTENT, Json(EmptyResponse {})))
 }
 
-pub fn router() -> Router {
-    Router::new()
-        .route("/app/", post(create_application).get(list_applications))
-        .route(
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
+        .api_route_with(
+            "/app/",
+            post(create_application).get(list_applications),
+            openapi_tag("Application"),
+        )
+        .api_route_with(
             "/app/:app_id/",
             get(get_application)
                 .put(update_application)
                 .patch(patch_application)
                 .delete(delete_application),
+            openapi_tag("Application"),
         )
 }
 
diff --git a/server/svix-server/src/v1/endpoints/attempt.rs b/server/svix-server/src/v1/endpoints/attempt.rs
index 37723366e..f8fe84680 100644
--- a/server/svix-server/src/v1/endpoints/attempt.rs
+++ b/server/svix-server/src/v1/endpoints/attempt.rs
@@ -14,23 +14,29 @@ use crate::{
     db::models::{endpoint, message, messagedestination},
     err_database,
     error::{Error, HttpError, Result},
-    queue::{MessageTask, TaskQueueProducer},
+    queue::MessageTask,
     v1::{
         endpoints::message::MessageOut,
         utils::{
-            apply_pagination, iterator_from_before_or_after, EmptyResponse, ListResponse,
-            MessageListFetchOptions, ModelOut, PaginationLimit, ReversibleIterator, ValidatedQuery,
+            apply_pagination, iterator_from_before_or_after, openapi_tag, EmptyResponse,
+            ListResponse, MessageListFetchOptions, ModelOut, PaginationLimit, ReversibleIterator,
+            ValidatedQuery,
         },
     },
+    AppState,
 };
-use axum::{
-    extract::{Extension, Path},
+use aide::axum::{
     routing::{get, post},
-    Json, Router,
+    ApiRouter,
+};
+use axum::{
+    extract::{Path, State},
+    Json,
 };
 use chrono::{DateTime, Utc};
 
 use hyper::StatusCode;
+use schemars::JsonSchema;
 use sea_orm::{entity::prelude::*, sea_query::Expr, DatabaseConnection, QueryOrder, QuerySelect};
 use serde::{Deserialize, Serialize};
 
@@ -40,7 +46,7 @@ use validator::Validate;
 use crate::db::models::messageattempt;
 use crate::v1::utils::Pagination;
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct MessageAttemptOut {
     pub url: String,
@@ -77,7 +83,7 @@ impl From<messageattempt::Model> for MessageAttemptOut {
 
 /// A model containing information on a given message plus additional fields on the last attempt for
 /// that message.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct AttemptedMessageOut {
     #[serde(flatten)]
@@ -108,7 +114,7 @@ impl AttemptedMessageOut {
 
 /// Additional parameters (besides pagination) in the query string for the "List Attempted Messages"
 /// endpoint.
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListAttemptedMessagesQueryParameters {
     #[validate]
     channel: Option<EventChannel>,
@@ -119,7 +125,7 @@ pub struct ListAttemptedMessagesQueryParameters {
 
 /// Fetches a list of [`AttemptedMessageOut`]s associated with a given app and endpoint.
 async fn list_attempted_messages(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(pagination): ValidatedQuery<Pagination<ReversibleIterator<MessageId>>>,
     ValidatedQuery(ListAttemptedMessagesQueryParameters {
         channel,
@@ -210,7 +216,7 @@ async fn list_attempted_messages(
 
 /// Additional parameters (besides pagination) in the query string for the "List Attempts by
 /// Endpoint" endpoint.
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListAttemptsByEndpointQueryParameters {
     status: Option<MessageStatus>,
     status_code_class: Option<StatusCodeClass>,
@@ -286,7 +292,7 @@ fn list_attempts_by_endpoint_or_message_filters(
 
 /// Fetches a list of [`MessageAttemptOut`]s for a given endpoint ID
 async fn list_attempts_by_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(pagination): ValidatedQuery<Pagination<ReversibleIterator<MessageAttemptId>>>,
     ValidatedQuery(ListAttemptsByEndpointQueryParameters {
         status,
@@ -341,7 +347,7 @@ async fn list_attempts_by_endpoint(
 }
 
 /// Flattens in a [`ListAttemptsByEndpointOrMsgQueryParameters`] and adds one extra query parameter
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListAttemptsByMsgQueryParameters {
     status: Option<MessageStatus>,
     status_code_class: Option<StatusCodeClass>,
@@ -357,7 +363,7 @@ pub struct ListAttemptsByMsgQueryParameters {
 
 /// Fetches a list of [`MessageAttemptOut`]s for a given message ID
 async fn list_attempts_by_msg(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(pagination): ValidatedQuery<Pagination<ReversibleIterator<MessageAttemptId>>>,
     ValidatedQuery(ListAttemptsByMsgQueryParameters {
         status,
@@ -430,7 +436,7 @@ async fn list_attempts_by_msg(
 
 /// A type combining information from [`messagedestination::Model`]s and [`endpoint::Model`]s to
 /// output information on attempted destinations
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct MessageEndpointOut {
     #[serde(flatten)]
@@ -458,7 +464,7 @@ impl MessageEndpointOut {
 }
 
 async fn list_attempted_destinations(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(mut pagination): ValidatedQuery<Pagination<EndpointId>>,
     Path((_app_id, msg_id)): Path<(ApplicationIdOrUid, MessageIdOrUid)>,
     permissions::Application { app }: permissions::Application,
@@ -504,7 +510,7 @@ async fn list_attempted_destinations(
     )))
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListAttemptsForEndpointQueryParameters {
     #[validate]
     pub channel: Option<EventChannel>,
@@ -514,7 +520,7 @@ pub struct ListAttemptsForEndpointQueryParameters {
 }
 
 async fn list_attempts_for_endpoint(
-    extension: Extension<DatabaseConnection>,
+    state: State<AppState>,
     pagination: ValidatedQuery<Pagination<ReversibleIterator<MessageAttemptId>>>,
     ValidatedQuery(ListAttemptsForEndpointQueryParameters {
         channel,
@@ -527,7 +533,7 @@ async fn list_attempts_for_endpoint(
     auth_app: permissions::Application,
 ) -> Result<Json<ListResponse<MessageAttemptOut>>> {
     list_messageattempts(
-        extension,
+        state,
         pagination,
         ValidatedQuery(AttemptListFetchOptions {
             endpoint_id: Some(endp_id),
@@ -543,7 +549,7 @@ async fn list_attempts_for_endpoint(
     .await
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct AttemptListFetchOptions {
     #[validate]
     pub endpoint_id: Option<EndpointIdOrUid>,
@@ -555,7 +561,7 @@ pub struct AttemptListFetchOptions {
 }
 
 async fn list_messageattempts(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(pagination): ValidatedQuery<Pagination<ReversibleIterator<MessageAttemptId>>>,
     ValidatedQuery(AttemptListFetchOptions {
         endpoint_id,
@@ -624,7 +630,7 @@ async fn list_messageattempts(
 }
 
 async fn get_messageattempt(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, msg_id, attempt_id)): Path<(
         ApplicationIdOrUid,
         MessageIdOrUid,
@@ -650,8 +656,9 @@ async fn get_messageattempt(
 }
 
 async fn resend_webhook(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(queue_tx): Extension<TaskQueueProducer>,
+    State(AppState {
+        ref db, queue_tx, ..
+    }): State<AppState>,
     Path((_app_id, msg_id, endp_id)): Path<(ApplicationIdOrUid, MessageIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<(StatusCode, Json<EmptyResponse>)> {
@@ -700,41 +707,49 @@ async fn resend_webhook(
     Ok((StatusCode::ACCEPTED, Json(EmptyResponse {})))
 }
 
-pub fn router() -> Router {
-    Router::new()
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
         // NOTE: [`list_messageattempts`] is deprecated
-        .route(
+        .api_route_with(
             "/app/:app_id/msg/:msg_id/attempt/",
             get(list_messageattempts),
+            openapi_tag("Message Attempt"),
         )
-        .route(
+        .api_route_with(
             "/app/:app_id/msg/:msg_id/attempt/:attempt_id/",
             get(get_messageattempt),
+            openapi_tag("Message Attempt"),
         )
-        .route(
+        .api_route_with(
             "/app/:app_id/msg/:msg_id/endpoint/",
             get(list_attempted_destinations),
+            openapi_tag("Message Attempt"),
         )
-        .route(
-            "/app/:app_id/msg/:msg_id/endpoint/:endp_id/resend/",
+        .api_route_with(
+            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/resend/",
             post(resend_webhook),
+            openapi_tag("Message Attempt"),
         )
         // NOTE: [`list_attempts_for_endpoint`] is deprecated
-        .route(
-            "/app/:app_id/msg/:msg_id/endpoint/:endp_id/attempt/",
+        .api_route_with(
+            "/app/:app_id/msg/:msg_id/endpoint/:endpoint_id/attempt/",
             get(list_attempts_for_endpoint),
+            openapi_tag("Message Attempt"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/msg/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/msg/",
             get(list_attempted_messages),
+            openapi_tag("Message Attempt"),
         )
-        .route(
-            "/app/:app_id/attempt/endpoint/:endp_id/",
+        .api_route_with(
+            "/app/:app_id/attempt/endpoint/:endpoint_id/",
             get(list_attempts_by_endpoint),
+            openapi_tag("Message Attempt"),
         )
-        .route(
+        .api_route_with(
             "/app/:app_id/attempt/msg/:msg_id/",
             get(list_attempts_by_msg),
+            openapi_tag("Message Attempt"),
         )
 }
 
diff --git a/server/svix-server/src/v1/endpoints/auth.rs b/server/svix-server/src/v1/endpoints/auth.rs
index c11dcce3d..44693725e 100644
--- a/server/svix-server/src/v1/endpoints/auth.rs
+++ b/server/svix-server/src/v1/endpoints/auth.rs
@@ -1,24 +1,43 @@
-use axum::{routing::post, Extension, Json, Router};
+use aide::axum::{routing::post, ApiRouter};
+use axum::{extract::State, Json};
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use validator::Validate;
 
 use crate::{
-    cfg::Configuration,
-    core::{permissions, security::generate_app_token},
+    core::{permissions, security::generate_app_token, types::FeatureFlagSet},
     error::{HttpError, Result},
-    v1::utils::api_not_implemented,
+    v1::utils::{api_not_implemented, openapi_tag, ValidatedJson},
+    AppState,
 };
 
-#[derive(Deserialize, Serialize)]
+#[derive(Deserialize, Serialize, JsonSchema)]
 pub struct DashboardAccessOut {
     pub url: String,
     pub token: String,
 }
 
-async fn dashboard_access(
-    Extension(cfg): Extension<Configuration>,
+#[derive(Deserialize, Serialize, Validate, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct AppPortalAccessIn {
+    /// The set of feature flags the created token will have access to.
+    #[serde(default, skip_serializing_if = "FeatureFlagSet::is_empty")]
+    pub feature_flags: FeatureFlagSet,
+}
+
+pub type AppPortalAccessOut = DashboardAccessOut;
+
+async fn app_portal_access(
+    State(AppState { cfg, .. }): State<AppState>,
     permissions::OrganizationWithApplication { app }: permissions::OrganizationWithApplication,
-) -> Result<Json<DashboardAccessOut>> {
-    let token = generate_app_token(&cfg.jwt_secret, app.org_id, app.id.clone())?;
+    ValidatedJson(data): ValidatedJson<AppPortalAccessIn>,
+) -> Result<Json<AppPortalAccessOut>> {
+    let token = generate_app_token(
+        &cfg.jwt_secret,
+        app.org_id,
+        app.id.clone(),
+        data.feature_flags,
+    )?;
 
     let login_key = serde_json::to_vec(&serde_json::json!({
         "appId": app.id,
@@ -35,8 +54,35 @@ async fn dashboard_access(
     Ok(Json(DashboardAccessOut { url, token }))
 }
 
-pub fn router() -> Router {
-    Router::new()
-        .route("/auth/dashboard-access/:app_id/", post(dashboard_access))
-        .route("/auth/logout/", post(api_not_implemented))
+async fn dashboard_access(
+    state: State<AppState>,
+    permissions: permissions::OrganizationWithApplication,
+) -> Result<Json<DashboardAccessOut>> {
+    app_portal_access(
+        state,
+        permissions,
+        ValidatedJson(AppPortalAccessIn {
+            feature_flags: FeatureFlagSet::default(),
+        }),
+    )
+    .await
+}
+
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
+        .api_route_with(
+            "/auth/dashboard-access/:app_id/",
+            post(dashboard_access),
+            openapi_tag("Authentication"),
+        )
+        .api_route_with(
+            "/auth/logout/",
+            post(api_not_implemented),
+            openapi_tag("Authentication"),
+        )
+        .api_route_with(
+            "/auth/app-portal-access/:app_id/",
+            post(app_portal_access),
+            openapi_tag("Authentication"),
+        )
 }
diff --git a/server/svix-server/src/v1/endpoints/endpoint/crud.rs b/server/svix-server/src/v1/endpoints/endpoint/crud.rs
index 7335be0c7..2c5d6b679 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/crud.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/crud.rs
@@ -1,7 +1,7 @@
 use std::{collections::HashSet, mem};
 
 use axum::{
-    extract::{Extension, Path},
+    extract::{Path, State},
     Json,
 };
 use hyper::StatusCode;
@@ -28,11 +28,12 @@ use crate::{
         EmptyResponse, ListResponse, ModelIn, ModelOut, Pagination, PaginationLimit, ValidatedJson,
         ValidatedQuery,
     },
+    AppState,
 };
 use hack::EventTypeNameResult;
 
 pub(super) async fn list_endpoints(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     pagination: ValidatedQuery<Pagination<EndpointId>>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<Json<ListResponse<EndpointOut>>> {
@@ -99,9 +100,12 @@ async fn create_endp_from_data(
 }
 
 pub(super) async fn create_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(ref cfg): Extension<Configuration>,
-    Extension(op_webhooks): Extension<OperationalWebhookSender>,
+    State(AppState {
+        ref db,
+        ref cfg,
+        op_webhooks,
+        ..
+    }): State<AppState>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<EndpointIn>,
 ) -> Result<(StatusCode, Json<EndpointOut>)> {
@@ -116,7 +120,7 @@ pub(super) async fn create_endpoint(
 }
 
 pub(super) async fn get_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<Json<EndpointOut>> {
@@ -160,9 +164,12 @@ async fn update_endp_from_data(
 }
 
 pub(super) async fn update_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(ref cfg): Extension<Configuration>,
-    Extension(ref op_webhooks): Extension<OperationalWebhookSender>,
+    State(AppState {
+        ref db,
+        ref cfg,
+        ref op_webhooks,
+        ..
+    }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(mut data): ValidatedJson<EndpointIn>,
@@ -188,9 +195,12 @@ pub(super) async fn update_endpoint(
 }
 
 pub(super) async fn patch_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(cfg): Extension<Configuration>,
-    Extension(ref op_webhooks): Extension<OperationalWebhookSender>,
+    State(AppState {
+        ref db,
+        cfg,
+        ref op_webhooks,
+        ..
+    }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<EndpointPatch>,
@@ -217,8 +227,11 @@ pub(super) async fn patch_endpoint(
 }
 
 pub(super) async fn delete_endpoint(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(op_webhooks): Extension<OperationalWebhookSender>,
+    State(AppState {
+        ref db,
+        ref op_webhooks,
+        ..
+    }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<(StatusCode, Json<EmptyResponse>)> {
@@ -242,10 +255,10 @@ pub(super) async fn delete_endpoint(
         .send_operational_webhook(
             &app.org_id,
             OperationalWebhook::EndpointDeleted(EndpointEvent {
-                app_id: &app.id,
-                app_uid: app.uid.as_ref(),
-                endpoint_id: &endpoint_id,
-                endpoint_uid: endpoint_uid.as_ref(),
+                app_id: app.id,
+                app_uid: app.uid,
+                endpoint_id,
+                endpoint_uid,
             }),
         )
         .await?;
@@ -299,32 +312,20 @@ async fn validate_event_types(
     }
 }
 
-fn validate_endpoint_url(url: &str, https_only: bool) -> Result<()> {
+fn validate_endpoint_url(url: &Url, https_only: bool) -> Result<()> {
     if !https_only {
         return Ok(());
     }
 
-    match Url::parse(url) {
-        Ok(url) => {
-            let scheme = url.scheme();
-            if scheme == "https" {
-                Ok(())
-            } else {
-                Err(HttpError::unprocessable_entity(vec![ValidationErrorItem {
-                    loc: vec!["body".to_owned(), "url".to_owned()],
-                    msg: "Endpoint URL schemes must be https when endpoint_https_only is set."
-                        .to_owned(),
-                    ty: "value_error".to_owned(),
-                }])
-                .into())
-            }
-        }
-
-        Err(_) => Err(HttpError::unprocessable_entity(vec![ValidationErrorItem {
+    let scheme = url.scheme();
+    if scheme == "https" {
+        Ok(())
+    } else {
+        Err(HttpError::unprocessable_entity(vec![ValidationErrorItem {
             loc: vec!["body".to_owned(), "url".to_owned()],
-            msg: "Endpoint URLs must be valid".to_owned(),
+            msg: "Endpoint URL schemes must be https when endpoint_https_only is set.".to_owned(),
             ty: "value_error".to_owned(),
         }])
-        .into()),
+        .into())
     }
 }
diff --git a/server/svix-server/src/v1/endpoints/endpoint/headers.rs b/server/svix-server/src/v1/endpoints/endpoint/headers.rs
index cc4c8e605..887131718 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/headers.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/headers.rs
@@ -1,9 +1,9 @@
 use axum::{
-    extract::{Extension, Path},
+    extract::{Path, State},
     Json,
 };
 use hyper::StatusCode;
-use sea_orm::{ActiveModelTrait, DatabaseConnection};
+use sea_orm::ActiveModelTrait;
 
 use super::{EndpointHeadersIn, EndpointHeadersOut, EndpointHeadersPatchIn};
 use crate::{
@@ -15,10 +15,11 @@ use crate::{
     db::models::endpoint,
     error::{HttpError, Result},
     v1::utils::{EmptyResponse, ModelIn, ValidatedJson},
+    AppState,
 };
 
 pub(super) async fn get_endpoint_headers(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<Json<EndpointHeadersOut>> {
@@ -36,7 +37,7 @@ pub(super) async fn get_endpoint_headers(
 }
 
 pub(super) async fn update_endpoint_headers(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<EndpointHeadersIn>,
@@ -56,7 +57,7 @@ pub(super) async fn update_endpoint_headers(
 }
 
 pub(super) async fn patch_endpoint_headers(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<EndpointHeadersPatchIn>,
diff --git a/server/svix-server/src/v1/endpoints/endpoint/mod.rs b/server/svix-server/src/v1/endpoints/endpoint/mod.rs
index a01c4099a..965b5a553 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/mod.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/mod.rs
@@ -20,7 +20,7 @@ use crate::{
     db::models::messagedestination,
     error::{self, HttpError},
     v1::utils::{
-        api_not_implemented,
+        api_not_implemented, openapi_tag,
         patch::{
             patch_field_non_nullable, patch_field_nullable, UnrequiredField,
             UnrequiredNullableField,
@@ -28,17 +28,20 @@ use crate::{
         validate_no_control_characters, validate_no_control_characters_unrequired,
         validation_error, ModelIn,
     },
+    AppState,
 };
 
-use axum::{
-    extract::{Extension, Path},
+use aide::axum::{
     routing::{get, post},
-    Json, Router,
+    ApiRouter,
 };
-use chrono::{DateTime, Utc};
-use sea_orm::{
-    ActiveValue::Set, ColumnTrait, DatabaseConnection, FromQueryResult, QueryFilter, QuerySelect,
+use axum::{
+    extract::{Path, Query, State},
+    Json,
 };
+use chrono::{DateTime, Duration, Utc};
+use schemars::JsonSchema;
+use sea_orm::{ActiveValue::Set, ColumnTrait, FromQueryResult, QueryFilter, QuerySelect};
 use serde::{Deserialize, Serialize};
 use std::{collections::HashMap, collections::HashSet};
 use url::Url;
@@ -96,37 +99,26 @@ fn validate_channels_endpoint_unrequired_nullable(
     }
 }
 
-pub fn validate_url(val: &str) -> std::result::Result<(), ValidationError> {
-    match Url::parse(val) {
-        Ok(url) => {
-            let scheme = url.scheme();
-            if scheme == "https" || scheme == "http" {
-                Ok(())
-            } else {
-                Err(validation_error(
-                    Some("url"),
-                    Some("Endpoint URL schemes must be http or https"),
-                ))
-            }
-        }
-
-        Err(_) => Err(validation_error(
+pub fn validate_url(url: &Url) -> std::result::Result<(), ValidationError> {
+    let scheme = url.scheme();
+    if scheme == "https" || scheme == "http" {
+        Ok(())
+    } else {
+        Err(validation_error(
             Some("url"),
-            Some("Endpoint URLs must be valid"),
-        )),
+            Some("Endpoint URL schemes must be http or https"),
+        ))
     }
 }
 
-fn validate_url_unrequired(
-    val: &UnrequiredField<String>,
-) -> std::result::Result<(), ValidationError> {
+fn validate_url_unrequired(val: &UnrequiredField<Url>) -> std::result::Result<(), ValidationError> {
     match val {
         UnrequiredField::Absent => Ok(()),
         UnrequiredField::Some(val) => validate_url(val),
     }
 }
 
-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Validate, ModelIn)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate, ModelIn, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointIn {
     #[serde(default)]
@@ -141,8 +133,9 @@ pub struct EndpointIn {
     #[validate]
     #[serde(skip_serializing_if = "Option::is_none")]
     pub uid: Option<EndpointUid>,
+
     #[validate(custom = "validate_url")]
-    pub url: String,
+    pub url: Url,
     #[validate(range(min = 1, message = "Endpoint versions must be at least one"))]
     pub version: u16,
     #[serde(default)]
@@ -203,7 +196,7 @@ impl ModelIn for EndpointIn {
         model.description = Set(description);
         model.rate_limit = Set(rate_limit.map(|x| x.into()));
         model.uid = Set(uid);
-        model.url = Set(url);
+        model.url = Set(url.into());
         model.version = Set(version.into());
         model.disabled = Set(disabled);
         model.event_types_ids = Set(event_types_ids);
@@ -211,7 +204,7 @@ impl ModelIn for EndpointIn {
     }
 }
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize, Validate, ModelIn)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, Validate, ModelIn, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointPatch {
     #[serde(default)]
@@ -229,7 +222,7 @@ pub struct EndpointPatch {
 
     #[validate(custom = "validate_url_unrequired")]
     #[serde(default)]
-    pub url: UnrequiredField<String>,
+    pub url: UnrequiredField<Url>,
 
     #[validate(custom = "validate_minimum_version_patch")]
     #[serde(default)]
@@ -279,6 +272,7 @@ impl ModelIn for EndpointPatch {
         } = self;
 
         let map = |x: u16| -> i32 { x.into() };
+        let url = url.map(String::from);
 
         patch_field_non_nullable!(model, description);
         patch_field_nullable!(model, rate_limit, map);
@@ -327,7 +321,7 @@ fn validate_minimum_version_patch(
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointOutCommon {
     pub description: String,
@@ -361,7 +355,7 @@ impl From<endpoint::Model> for EndpointOutCommon {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointOut {
     #[serde(flatten)]
@@ -381,7 +375,7 @@ impl From<(endpoint::Model, Metadata)> for EndpointOut {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointSecretRotateIn {
     #[validate]
@@ -389,22 +383,21 @@ pub struct EndpointSecretRotateIn {
     key: Option<EndpointSecret>,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointSecretOut {
     pub key: EndpointSecret,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Validate, Serialize, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Validate, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct RecoverIn {
     pub since: DateTime<Utc>,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize, Serialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointHeadersIn {
-    #[validate]
     pub headers: EndpointHeaders,
 }
 
@@ -417,7 +410,7 @@ impl ModelIn for EndpointHeadersIn {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Default)]
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Default, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointHeadersOut {
     pub headers: HashMap<String, String>,
@@ -448,7 +441,7 @@ impl From<EndpointHeaders> for EndpointHeadersOut {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize, Serialize)]
+#[derive(Clone, Debug, PartialEq, Eq, Validate, Deserialize, Serialize, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EndpointHeadersPatchIn {
     #[validate]
@@ -481,12 +474,48 @@ impl ModelIn for EndpointHeadersPatchIn {
     }
 }
 
-#[derive(Deserialize, Serialize)]
+#[derive(Deserialize, JsonSchema)]
+struct EndpointStatsRange {
+    since: Option<DateTime<Utc>>,
+    until: Option<DateTime<Utc>>,
+}
+
+impl EndpointStatsRange {
+    fn validate_unwrap_or_default(self) -> error::Result<(DateTime<Utc>, DateTime<Utc>)> {
+        let until = self.until.unwrap_or_else(Utc::now);
+
+        if until > Utc::now() {
+            return Err(HttpError::bad_request(
+                Some("invalid_range".into()),
+                Some("'until' cannot be in the future".into()),
+            )
+            .into());
+        }
+
+        let since = self.since.unwrap_or(until - Duration::days(28));
+
+        // Add five minutes so that people can easily just do `now() - 28 days`
+        // without having to worry about clock sync
+        if until - since > (Duration::days(28) + Duration::minutes(5)) {
+            return Err(HttpError::bad_request(
+                Some("invalid_range".into()),
+                Some(format!(
+                    "'since' cannot be more than 28 days prior to {until}"
+                )),
+            )
+            .into());
+        }
+
+        Ok((since, until))
+    }
+}
+
+#[derive(Deserialize, Serialize, JsonSchema)]
 pub struct EndpointStatsOut {
-    success: i64,
-    pending: i64,
-    sending: i64,
-    fail: i64,
+    pub success: i64,
+    pub pending: i64,
+    pub sending: i64,
+    pub fail: i64,
 }
 
 #[derive(Debug, FromQueryResult)]
@@ -496,10 +525,13 @@ pub struct EndpointStatsQueryOut {
 }
 
 async fn endpoint_stats(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
+    Query(range): Query<EndpointStatsRange>,
     permissions::Application { app }: permissions::Application,
-) -> crate::error::Result<Json<EndpointStatsOut>> {
+) -> error::Result<Json<EndpointStatsOut>> {
+    let (since, until) = range.validate_unwrap_or_default()?;
+
     let endpoint = ctx!(
         crate::db::models::endpoint::Entity::secure_find_by_id_or_uid(app.id, endp_id)
             .one(db)
@@ -514,11 +546,8 @@ async fn endpoint_stats(
             .column(messagedestination::Column::Status)
             .column_as(messagedestination::Column::Status.count(), "count")
             .group_by(messagedestination::Column::Status)
-            .filter(
-                messagedestination::Column::Id.gte(MessageEndpointId::start_id(
-                    chrono::Utc::now() - chrono::Duration::days(28),
-                )),
-            )
+            .filter(messagedestination::Column::Id.gte(MessageEndpointId::start_id(since)),)
+            .filter(messagedestination::Column::Id.lte(MessageEndpointId::start_id(until)),)
             .into_model::<EndpointStatsQueryOut>()
             .all(db)
             .await
@@ -536,41 +565,52 @@ async fn endpoint_stats(
     }))
 }
 
-pub fn router() -> Router {
-    Router::new()
-        .route(
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
+        .api_route_with(
             "/app/:app_id/endpoint/",
             post(crud::create_endpoint).get(crud::list_endpoints),
+            openapi_tag("Endpoint"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/",
             get(crud::get_endpoint)
                 .put(crud::update_endpoint)
                 .patch(crud::patch_endpoint)
                 .delete(crud::delete_endpoint),
+            openapi_tag("Endpoint"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/secret/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/secret/",
             get(secrets::get_endpoint_secret),
+            openapi_tag("Endpoint"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/secret/rotate/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/secret/rotate/",
             post(secrets::rotate_endpoint_secret),
+            openapi_tag("Endpoint"),
         )
-        .route("/app/:app_id/endpoint/:endp_id/stats/", get(endpoint_stats))
-        .route(
-            "/app/:app_id/endpoint/:endp_id/send-example/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/stats/",
+            get(endpoint_stats),
+            openapi_tag("Endpoint"),
+        )
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/send-example/",
             post(api_not_implemented),
+            openapi_tag("Endpoint"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/recover/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/recover/",
             post(recovery::recover_failed_webhooks),
+            openapi_tag("Endpoint"),
         )
-        .route(
-            "/app/:app_id/endpoint/:endp_id/headers/",
+        .api_route_with(
+            "/app/:app_id/endpoint/:endpoint_id/headers/",
             get(headers::get_endpoint_headers)
                 .patch(headers::patch_endpoint_headers)
                 .put(headers::update_endpoint_headers),
+            openapi_tag("Endpoint"),
         )
 }
 
@@ -579,9 +619,8 @@ mod tests {
 
     use crate::core::types::EndpointHeaders;
 
-    use super::{
-        validate_url, EndpointHeadersIn, EndpointHeadersOut, EndpointHeadersPatchIn, EndpointIn,
-    };
+    use super::{validate_url, EndpointHeadersOut, EndpointHeadersPatchIn, EndpointIn};
+    use reqwest::Url;
     use serde_json::json;
     use std::collections::{HashMap, HashSet};
     use validator::Validate;
@@ -609,7 +648,8 @@ mod tests {
 
         let invalid_2: EndpointIn = serde_json::from_value(json!({
              "version": VERSION_VALID,
-             "url": URL_INVALID
+             "url": URL_VALID,
+             "channels": EVENT_CHANNELS_INVALID
         }))
         .unwrap();
 
@@ -634,16 +674,13 @@ mod tests {
         }))
         .unwrap();
 
-        let invalid_6: EndpointIn = serde_json::from_value(json!({
+        let invalid_6: Result<EndpointIn, _> = serde_json::from_value(json!({
              "version": VERSION_VALID,
-             "url": URL_VALID,
-             "channels": EVENT_CHANNELS_INVALID
-        }))
-        .unwrap();
+             "url": URL_INVALID
+        }));
+        assert!(invalid_6.is_err());
 
-        for e in [
-            invalid_1, invalid_2, invalid_3, invalid_4, invalid_5, invalid_6,
-        ] {
+        for e in [invalid_1, invalid_2, invalid_3, invalid_4, invalid_5] {
             assert!(e.validate().is_err());
         }
 
@@ -659,20 +696,6 @@ mod tests {
         valid.validate().unwrap();
     }
 
-    #[test]
-    fn test_endpoint_headers_in_validation() {
-        let headers_valid = HashMap::from([("x-valid", "1")]);
-        let headers_invalid = HashMap::from([("x-invalid???", "1")]);
-
-        let invalid: EndpointHeadersIn =
-            serde_json::from_value(json!({ "headers": headers_invalid })).unwrap();
-        assert!(invalid.validate().is_err());
-
-        let valid: EndpointHeadersIn =
-            serde_json::from_value(json!({ "headers": headers_valid })).unwrap();
-        valid.validate().unwrap();
-    }
-
     #[test]
     fn test_endpoint_headers_sensitive() {
         let headers = EndpointHeaders(HashMap::from([
@@ -709,15 +732,14 @@ mod tests {
 
     #[test]
     fn test_url_validation() {
-        let valid_https = "https://test.url";
-        let valid_http = "http://test.url";
-        let invalid_scheme = "anythingelse://test.url";
+        let valid_https = Url::parse("https://test.url").unwrap();
+        let valid_http = Url::parse("http://test.url").unwrap();
+        let invalid_scheme = Url::parse("anythingelse://test.url").unwrap();
         let invalid_format = "http://[:::1]";
 
-        assert!(validate_url(valid_https).is_ok());
-        assert!(validate_url(valid_http).is_ok());
-        assert!(validate_url(invalid_scheme).is_err());
-        assert!(validate_url(invalid_format).is_err());
+        assert!(validate_url(&valid_https).is_ok());
+        assert!(validate_url(&valid_http).is_ok());
+        assert!(validate_url(&invalid_scheme).is_err());
 
         let valid_https: EndpointIn =
             serde_json::from_value(json!({"url": valid_https, "version": 1})).unwrap();
@@ -725,12 +747,12 @@ mod tests {
             serde_json::from_value(json!({"url": valid_http, "version": 1})).unwrap();
         let invalid_scheme: EndpointIn =
             serde_json::from_value(json!({"url": invalid_scheme, "version": 1})).unwrap();
-        let invalid_format: EndpointIn =
-            serde_json::from_value(json!({"url": invalid_format, "version": 1})).unwrap();
+        let invalid_format: Result<EndpointIn, _> =
+            serde_json::from_value(json!({"url": invalid_format, "version": 1}));
 
         assert!(valid_https.validate().is_ok());
         assert!(valid_http.validate().is_ok());
         assert!(invalid_scheme.validate().is_err());
-        assert!(invalid_format.validate().is_err());
+        assert!(invalid_format.is_err());
     }
 }
diff --git a/server/svix-server/src/v1/endpoints/endpoint/recovery.rs b/server/svix-server/src/v1/endpoints/endpoint/recovery.rs
index bcdbb2dba..22ed38b2e 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/recovery.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/recovery.rs
@@ -1,5 +1,5 @@
 use axum::{
-    extract::{Extension, Path},
+    extract::{Path, State},
     Json,
 };
 use chrono::{DateTime, Utc};
@@ -21,6 +21,7 @@ use crate::{
     error::{HttpError, Result, ValidationErrorItem},
     queue::{MessageTask, TaskQueueProducer},
     v1::utils::{EmptyResponse, ValidatedJson},
+    AppState,
 };
 
 async fn bulk_recover_failed_messages(
@@ -74,8 +75,9 @@ async fn bulk_recover_failed_messages(
 }
 
 pub(super) async fn recover_failed_webhooks(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(queue_tx): Extension<TaskQueueProducer>,
+    State(AppState {
+        ref db, queue_tx, ..
+    }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<RecoverIn>,
diff --git a/server/svix-server/src/v1/endpoints/endpoint/secrets.rs b/server/svix-server/src/v1/endpoints/endpoint/secrets.rs
index 66cc5c242..031244102 100644
--- a/server/svix-server/src/v1/endpoints/endpoint/secrets.rs
+++ b/server/svix-server/src/v1/endpoints/endpoint/secrets.rs
@@ -1,16 +1,16 @@
 use axum::{
-    extract::{Extension, Path},
+    extract::{Path, State},
     Json,
 };
 use chrono::{Duration, Utc};
 use hyper::StatusCode;
+use sea_orm::ActiveModelTrait;
 use sea_orm::ActiveValue::Set;
-use sea_orm::{ActiveModelTrait, DatabaseConnection};
 use std::iter;
 
 use super::{EndpointSecretOut, EndpointSecretRotateIn};
 use crate::{
-    cfg::{Configuration, DefaultSignatureType},
+    cfg::DefaultSignatureType,
     core::{
         cryptography::Encryption,
         permissions,
@@ -23,6 +23,7 @@ use crate::{
     db::models::endpoint,
     error::{HttpError, Result},
     v1::utils::{EmptyResponse, ValidatedJson},
+    AppState,
 };
 
 pub(super) fn generate_secret(
@@ -33,8 +34,7 @@ pub(super) fn generate_secret(
 }
 
 pub(super) async fn get_endpoint_secret(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(cfg): Extension<Configuration>,
+    State(AppState { ref db, cfg, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
 ) -> Result<Json<EndpointSecretOut>> {
@@ -50,8 +50,7 @@ pub(super) async fn get_endpoint_secret(
 }
 
 pub(super) async fn rotate_endpoint_secret(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(cfg): Extension<Configuration>,
+    State(AppState { ref db, cfg, .. }): State<AppState>,
     Path((_app_id, endp_id)): Path<(ApplicationIdOrUid, EndpointIdOrUid)>,
     permissions::Application { app }: permissions::Application,
     ValidatedJson(data): ValidatedJson<EndpointSecretRotateIn>,
diff --git a/server/svix-server/src/v1/endpoints/event_type.rs b/server/svix-server/src/v1/endpoints/event_type.rs
index 3aca6ebbf..0a9e27738 100644
--- a/server/svix-server/src/v1/endpoints/event_type.rs
+++ b/server/svix-server/src/v1/endpoints/event_type.rs
@@ -4,13 +4,13 @@
 use crate::{
     core::{
         permissions,
-        types::{EventTypeName, RetrySchedule},
+        types::{EventTypeName, FeatureFlag, RetrySchedule},
     },
     ctx,
     db::models::eventtype,
     error::{HttpError, Result},
     v1::utils::{
-        api_not_implemented,
+        api_not_implemented, openapi_tag,
         patch::{
             patch_field_non_nullable, patch_field_nullable, UnrequiredField,
             UnrequiredNullableField,
@@ -19,21 +19,26 @@ use crate::{
         ListResponse, ModelIn, ModelOut, Pagination, PaginationLimit, ValidatedJson,
         ValidatedQuery,
     },
+    AppState,
 };
-use axum::{
-    extract::{Extension, Path},
+use aide::axum::{
     routing::{get, post},
-    Json, Router,
+    ApiRouter,
+};
+use axum::{
+    extract::{Path, State},
+    Json,
 };
 use chrono::{DateTime, Utc};
 use hyper::StatusCode;
+use schemars::JsonSchema;
 use sea_orm::{entity::prelude::*, ActiveValue::Set, QueryOrder};
-use sea_orm::{ActiveModelTrait, DatabaseConnection, QuerySelect};
+use sea_orm::{ActiveModelTrait, QuerySelect};
 use serde::{Deserialize, Serialize};
 use svix_server_derive::{ModelIn, ModelOut};
 use validator::Validate;
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate, ModelIn)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate, ModelIn, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EventTypeIn {
     pub name: EventTypeName,
@@ -42,6 +47,8 @@ pub struct EventTypeIn {
     #[serde(default, rename = "archived")]
     pub deleted: bool,
     pub schemas: Option<eventtype::Schema>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub feature_flag: Option<FeatureFlag>,
 }
 
 // FIXME: This can and should be a derive macro
@@ -54,16 +61,18 @@ impl ModelIn for EventTypeIn {
             description,
             deleted,
             schemas,
+            feature_flag,
         } = self;
 
         model.name = Set(name);
         model.description = Set(description);
         model.deleted = Set(deleted);
         model.schemas = Set(schemas);
+        model.feature_flag = Set(feature_flag);
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Deserialize, Validate, ModelIn)]
+#[derive(Clone, Debug, PartialEq, Deserialize, Validate, ModelIn, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 struct EventTypeUpdate {
     #[validate(custom = "validate_no_control_characters")]
@@ -71,6 +80,8 @@ struct EventTypeUpdate {
     #[serde(default, rename = "archived")]
     deleted: bool,
     schemas: Option<eventtype::Schema>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    feature_flag: Option<FeatureFlag>,
 }
 
 // FIXME: This can and should be a derive macro
@@ -82,15 +93,17 @@ impl ModelIn for EventTypeUpdate {
             description,
             deleted,
             schemas,
+            feature_flag,
         } = self;
 
         model.description = Set(description);
         model.deleted = Set(deleted);
         model.schemas = Set(schemas);
+        model.feature_flag = Set(feature_flag);
     }
 }
 
-#[derive(Deserialize, ModelIn, Serialize, Validate)]
+#[derive(Deserialize, ModelIn, Serialize, Validate, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 struct EventTypePatch {
     #[serde(default, skip_serializing_if = "UnrequiredField::is_absent")]
@@ -106,6 +119,9 @@ struct EventTypePatch {
 
     #[serde(default, skip_serializing_if = "UnrequiredNullableField::is_absent")]
     schemas: UnrequiredNullableField<eventtype::Schema>,
+
+    #[serde(default, skip_serializing_if = "UnrequiredNullableField::is_absent")]
+    feature_flag: UnrequiredNullableField<FeatureFlag>,
 }
 
 impl ModelIn for EventTypePatch {
@@ -116,15 +132,17 @@ impl ModelIn for EventTypePatch {
             description,
             deleted,
             schemas,
+            feature_flag,
         } = self;
 
         patch_field_non_nullable!(model, description);
         patch_field_non_nullable!(model, deleted);
         patch_field_nullable!(model, schemas);
+        patch_field_nullable!(model, feature_flag);
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct EventTypeOut {
     pub name: EventTypeName,
@@ -135,6 +153,7 @@ pub struct EventTypeOut {
 
     pub created_at: DateTime<Utc>,
     pub updated_at: DateTime<Utc>,
+    pub feature_flag: Option<FeatureFlag>,
 }
 
 impl EventTypeOut {
@@ -154,6 +173,7 @@ impl From<eventtype::Model> for EventTypeOut {
             description: model.description,
             deleted: model.deleted,
             schemas: model.schemas,
+            feature_flag: model.feature_flag,
 
             created_at: model.created_at.into(),
             updated_at: model.updated_at.into(),
@@ -161,7 +181,7 @@ impl From<eventtype::Model> for EventTypeOut {
     }
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListFetchOptions {
     #[serde(default)]
     pub include_archived: bool,
@@ -170,10 +190,14 @@ pub struct ListFetchOptions {
 }
 
 async fn list_event_types(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     pagination: ValidatedQuery<Pagination<EventTypeName>>,
     fetch_options: ValidatedQuery<ListFetchOptions>,
-    permissions::ReadAll { org_id }: permissions::ReadAll,
+    permissions::ReadAll {
+        org_id,
+        feature_flags,
+        ..
+    }: permissions::ReadAll,
 ) -> Result<Json<ListResponse<EventTypeOut>>> {
     let PaginationLimit(limit) = pagination.limit;
     let iterator = pagination.iterator.clone();
@@ -190,6 +214,10 @@ async fn list_event_types(
         query = query.filter(eventtype::Column::Name.gt(iterator));
     }
 
+    if let permissions::AllowedFeatureFlags::Some(flags) = feature_flags {
+        query = eventtype::Entity::filter_feature_flags(query, flags);
+    }
+
     Ok(Json(EventTypeOut::list_response_no_prev(
         ctx!(query.all(db).await)?
             .into_iter()
@@ -206,7 +234,7 @@ async fn list_event_types(
 }
 
 async fn create_event_type(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<EventTypeIn>,
 ) -> Result<(StatusCode, Json<EventTypeOut>)> {
@@ -242,21 +270,25 @@ async fn create_event_type(
 }
 
 async fn get_event_type(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
-    permissions::ReadAll { org_id }: permissions::ReadAll,
+    permissions::ReadAll {
+        org_id,
+        feature_flags,
+        ..
+    }: permissions::ReadAll,
 ) -> Result<Json<EventTypeOut>> {
-    let evtype = ctx!(
-        eventtype::Entity::secure_find_by_name(org_id, evtype_name)
-            .one(db)
-            .await
-    )?
-    .ok_or_else(|| HttpError::not_found(None, None))?;
+    let mut query = eventtype::Entity::secure_find_by_name(org_id, evtype_name);
+    if let permissions::AllowedFeatureFlags::Some(flags) = feature_flags {
+        query = eventtype::Entity::filter_feature_flags(query, flags);
+    }
+    let evtype = ctx!(query.one(db).await)?.ok_or_else(|| HttpError::not_found(None, None))?;
+
     Ok(Json(evtype.into()))
 }
 
 async fn update_event_type(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<EventTypeUpdate>,
@@ -292,7 +324,7 @@ async fn update_event_type(
 }
 
 async fn patch_event_type(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<EventTypePatch>,
@@ -312,7 +344,7 @@ async fn patch_event_type(
 }
 
 async fn delete_event_type(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
     permissions::Organization { org_id }: permissions::Organization,
 ) -> Result<(StatusCode, Json<EmptyResponse>)> {
@@ -329,14 +361,14 @@ async fn delete_event_type(
     Ok((StatusCode::NO_CONTENT, Json(EmptyResponse {})))
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct RetryScheduleInOut {
     retry_schedule: Option<RetrySchedule>,
 }
 
 async fn get_retry_schedule(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
     permissions::Organization { org_id }: permissions::Organization,
 ) -> Result<Json<RetryScheduleInOut>> {
@@ -356,7 +388,7 @@ async fn get_retry_schedule(
 }
 
 async fn update_retry_schedule(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path(evtype_name): Path<EventTypeName>,
     permissions::Organization { org_id }: permissions::Organization,
     ValidatedJson(data): ValidatedJson<RetryScheduleInOut>,
@@ -379,26 +411,30 @@ async fn update_retry_schedule(
     Ok(Json(retry_schedule))
 }
 
-pub fn router() -> Router {
-    Router::new()
-        .route(
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
+        .api_route_with(
             "/event-type/",
             post(create_event_type).get(list_event_types),
+            openapi_tag("Event Type"),
         )
-        .route(
+        .api_route_with(
             "/event-type/:event_type_name/",
             get(get_event_type)
                 .put(update_event_type)
                 .patch(patch_event_type)
                 .delete(delete_event_type),
+            openapi_tag("Event Type"),
         )
-        .route(
+        .api_route_with(
             "/event-type/:event_type_name/retry-schedule/",
             get(get_retry_schedule).put(update_retry_schedule),
+            openapi_tag("Event Type"),
         )
-        .route(
+        .api_route_with(
             "/event-type/schema/generate-example/",
             post(api_not_implemented),
+            openapi_tag("Event Type"),
         )
 }
 
diff --git a/server/svix-server/src/v1/endpoints/health.rs b/server/svix-server/src/v1/endpoints/health.rs
index 6d5368af6..9ddeb430a 100644
--- a/server/svix-server/src/v1/endpoints/health.rs
+++ b/server/svix-server/src/v1/endpoints/health.rs
@@ -3,13 +3,15 @@
 
 use std::time::Duration;
 
-use axum::{http::StatusCode, routing::get, Extension, Json, Router};
-use sea_orm::{query::Statement, ConnectionTrait, DatabaseBackend, DatabaseConnection};
+use aide::axum::ApiRouter;
+use axum::{extract::State, http::StatusCode, routing::get, Json};
+use sea_orm::{query::Statement, ConnectionTrait, DatabaseBackend};
 use serde::{Deserialize, Serialize};
 
 use crate::{
-    core::cache::{kv_def, Cache, CacheBehavior, CacheKey, CacheValue},
-    queue::{QueueTask, TaskQueueProducer},
+    core::cache::{kv_def, CacheBehavior, CacheKey, CacheValue},
+    queue::QueueTask,
+    AppState,
 };
 
 async fn ping() -> StatusCode {
@@ -74,9 +76,12 @@ struct HealthCheckCacheValue(());
 kv_def!(HealthCheckCacheKey, HealthCheckCacheValue);
 
 async fn health(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(queue_tx): Extension<TaskQueueProducer>,
-    Extension(cache): Extension<Cache>,
+    State(AppState {
+        ref db,
+        queue_tx,
+        cache,
+        ..
+    }): State<AppState>,
 ) -> (StatusCode, Json<HealthReport>) {
     // SELECT 1 FROM any table
     let database: HealthStatus = db
@@ -117,8 +122,8 @@ async fn health(
     )
 }
 
-pub fn router() -> Router {
-    Router::new()
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
         .route("/health/ping/", get(ping).head(ping))
         .route("/health/", get(health).head(health))
 }
diff --git a/server/svix-server/src/v1/endpoints/message.rs b/server/svix-server/src/v1/endpoints/message.rs
index 847377823..519ddb6c7 100644
--- a/server/svix-server/src/v1/endpoints/message.rs
+++ b/server/svix-server/src/v1/endpoints/message.rs
@@ -2,7 +2,6 @@
 // SPDX-License-Identifier: MIT
 
 use crate::{
-    cache::Cache,
     core::{
         message_app::CreateMessageApp,
         permissions,
@@ -13,23 +12,28 @@ use crate::{
     },
     ctx, err_generic,
     error::{HttpError, Result},
-    queue::{MessageTaskBatch, TaskQueueProducer},
+    queue::MessageTaskBatch,
     v1::utils::{
-        apply_pagination, iterator_from_before_or_after, validation_error, ListResponse,
-        MessageListFetchOptions, ModelIn, ModelOut, PaginationLimit, ReversibleIterator,
-        ValidatedJson, ValidatedQuery,
+        apply_pagination, iterator_from_before_or_after, openapi_tag, validation_error,
+        ListResponse, MessageListFetchOptions, ModelIn, ModelOut, PaginationLimit,
+        ReversibleIterator, ValidatedJson, ValidatedQuery,
     },
+    AppState,
 };
-use axum::{
-    extract::{Extension, Path},
+use aide::axum::{
     routing::{get, post},
-    Json, Router,
+    ApiRouter,
+};
+use axum::{
+    extract::{Path, State},
+    Json,
 };
 use chrono::{DateTime, Duration, Utc};
 use hyper::StatusCode;
+use schemars::JsonSchema;
 use sea_orm::entity::prelude::*;
+use sea_orm::ActiveModelTrait;
 use sea_orm::{sea_query::Expr, ActiveValue::Set};
-use sea_orm::{ActiveModelTrait, DatabaseConnection};
 use serde::{Deserialize, Serialize};
 
 use svix_server_derive::{ModelIn, ModelOut};
@@ -64,7 +68,7 @@ pub fn validate_message_in_payload(
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate, ModelIn)]
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate, ModelIn, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct MessageIn {
     #[validate]
@@ -107,7 +111,7 @@ impl ModelIn for MessageIn {
     }
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ModelOut, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct MessageOut {
     #[serde(rename = "eventId")]
@@ -154,7 +158,7 @@ fn default_90() -> i64 {
     90
 }
 
-#[derive(Clone, Debug, Deserialize, Validate)]
+#[derive(Clone, Debug, Deserialize, Validate, JsonSchema)]
 pub struct ListMessagesQueryParams {
     #[validate]
     channel: Option<EventChannel>,
@@ -165,7 +169,7 @@ pub struct ListMessagesQueryParams {
 }
 
 async fn list_messages(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     ValidatedQuery(pagination): ValidatedQuery<Pagination<ReversibleIterator<MessageId>>>,
     ValidatedQuery(ListMessagesQueryParams {
         channel,
@@ -212,16 +216,19 @@ async fn list_messages(
     Ok(Json(MessageOut::list_response(out, limit as usize, false)))
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct CreateMessageQueryParams {
     #[serde(default = "default_true")]
     with_content: bool,
 }
 
 async fn create_message(
-    Extension(ref db): Extension<DatabaseConnection>,
-    Extension(queue_tx): Extension<TaskQueueProducer>,
-    Extension(cache): Extension<Cache>,
+    State(AppState {
+        ref db,
+        queue_tx,
+        cache,
+        ..
+    }): State<AppState>,
     ValidatedQuery(CreateMessageQueryParams { with_content }): ValidatedQuery<
         CreateMessageQueryParams,
     >,
@@ -229,7 +236,7 @@ async fn create_message(
     ValidatedJson(data): ValidatedJson<MessageIn>,
 ) -> Result<(StatusCode, Json<MessageOut>)> {
     let create_message_app = CreateMessageApp::layered_fetch(
-        cache,
+        &cache,
         db,
         Some(app.clone()),
         app.org_id.clone(),
@@ -273,13 +280,13 @@ async fn create_message(
     Ok((StatusCode::ACCEPTED, Json(msg_out)))
 }
 
-#[derive(Debug, Deserialize, Validate)]
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
 pub struct GetMessageQueryParams {
     #[serde(default = "default_true")]
     with_content: bool,
 }
 async fn get_message(
-    Extension(ref db): Extension<DatabaseConnection>,
+    State(AppState { ref db, .. }): State<AppState>,
     Path((_app_id, msg_id)): Path<(ApplicationIdOrUid, MessageIdOrUid)>,
     ValidatedQuery(GetMessageQueryParams { with_content }): ValidatedQuery<GetMessageQueryParams>,
     permissions::Application { app }: permissions::Application,
@@ -298,10 +305,18 @@ async fn get_message(
     Ok(Json(msg_out))
 }
 
-pub fn router() -> Router {
-    Router::new()
-        .route("/app/:app_id/msg/", post(create_message).get(list_messages))
-        .route("/app/:app_id/msg/:msg_id/", get(get_message))
+pub fn router() -> ApiRouter<AppState> {
+    ApiRouter::new()
+        .api_route_with(
+            "/app/:app_id/msg/",
+            post(create_message).get(list_messages),
+            openapi_tag("Message"),
+        )
+        .api_route_with(
+            "/app/:app_id/msg/:msg_id/",
+            get(get_message),
+            openapi_tag("Message"),
+        )
 }
 
 #[cfg(test)]
diff --git a/server/svix-server/src/v1/mod.rs b/server/svix-server/src/v1/mod.rs
index 239814a41..660e7d307 100644
--- a/server/svix-server/src/v1/mod.rs
+++ b/server/svix-server/src/v1/mod.rs
@@ -1,24 +1,37 @@
 // SPDX-FileCopyrightText: © 2022 Svix Authors
 // SPDX-License-Identifier: MIT
 
-use axum::Router;
+use aide::axum::ApiRouter;
+use tower_http::trace::TraceLayer;
+
+use crate::{
+    core::otel_spans::{AxumOtelOnFailure, AxumOtelOnResponse, AxumOtelSpanCreator},
+    AppState,
+};
 
 pub mod endpoints;
 pub mod utils;
 
-pub fn router() -> Router {
-    let ret = Router::new()
+pub fn router() -> ApiRouter<AppState> {
+    let ret: ApiRouter<AppState> = ApiRouter::new()
         .merge(endpoints::health::router())
         .merge(endpoints::auth::router())
         .merge(endpoints::application::router())
         .merge(endpoints::endpoint::router())
         .merge(endpoints::event_type::router())
         .merge(endpoints::message::router())
-        .merge(endpoints::attempt::router());
+        .merge(endpoints::attempt::router())
+        .layer(
+            TraceLayer::new_for_http()
+                .make_span_with(AxumOtelSpanCreator)
+                .on_response(AxumOtelOnResponse)
+                .on_failure(AxumOtelOnFailure),
+        );
 
     #[cfg(debug_assertions)]
     if cfg!(debug_assertions) {
-        return ret.merge(development::router());
+        let dev_router: ApiRouter<AppState> = development::router().into();
+        return ret.merge(dev_router);
     }
     ret
 }
@@ -30,6 +43,7 @@ mod development {
 
     use crate::error::{Error, Result};
     use crate::v1::utils::EmptyResponse;
+    use crate::AppState;
 
     struct EchoData {
         pub headers: String,
@@ -56,7 +70,7 @@ mod development {
         Ok(Json(EmptyResponse {}))
     }
 
-    pub fn router() -> Router {
+    pub fn router() -> Router<AppState> {
         Router::new().route("/development/echo/", get(echo).post(echo))
     }
 }
diff --git a/server/svix-server/src/v1/utils/mod.rs b/server/svix-server/src/v1/utils/mod.rs
index 3851e963a..12cf0eb65 100644
--- a/server/svix-server/src/v1/utils/mod.rs
+++ b/server/svix-server/src/v1/utils/mod.rs
@@ -1,8 +1,16 @@
 // SPDX-FileCopyrightText: © 2022 Svix Authors
 // SPDX-License-Identifier: MIT
 
-use std::{borrow::Cow, collections::HashSet, error::Error as StdError, ops::Deref, str::FromStr};
+use std::{
+    borrow::Cow,
+    collections::HashSet,
+    error::Error as StdError,
+    ops::Deref,
+    str::FromStr,
+    time::{SystemTime, UNIX_EPOCH},
+};
 
+use aide::{transform::TransformPathItem, OperationInput, OperationIo};
 use axum::{
     async_trait,
     body::HttpBody,
@@ -12,6 +20,7 @@ use axum::{
 use chrono::{DateTime, Utc};
 use http::{request::Parts, Request};
 use regex::Regex;
+use schemars::JsonSchema;
 use sea_orm::{ColumnTrait, QueryFilter, QueryOrder, QuerySelect};
 use serde::{de::DeserializeOwned, Deserialize, Serialize};
 
@@ -35,8 +44,8 @@ const PAGINATION_LIMIT_CAP_LIMIT: u64 = 250;
 // figure at some point
 const PAGINATION_LIMIT_ERROR: &str = "Given limit must not exceed 250";
 
-#[derive(Debug, Deserialize, Validate)]
-pub struct Pagination<T: Validate> {
+#[derive(Debug, Deserialize, Validate, JsonSchema)]
+pub struct Pagination<T: Validate + JsonSchema> {
     #[validate]
     #[serde(default = "default_limit")]
     pub limit: PaginationLimit,
@@ -44,7 +53,7 @@ pub struct Pagination<T: Validate> {
     pub iterator: Option<T>,
 }
 
-#[derive(Debug)]
+#[derive(Debug, JsonSchema)]
 pub struct PaginationLimit(pub u64);
 
 impl<'de> Deserialize<'de> for PaginationLimit {
@@ -114,6 +123,16 @@ impl<T: Validate> Validate for ReversibleIterator<T> {
     }
 }
 
+impl<T: Validate + JsonSchema> JsonSchema for ReversibleIterator<T> {
+    fn schema_name() -> String {
+        format!("ReversibleIterator_{}", T::schema_name())
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        T::json_schema(gen)
+    }
+}
+
 /// For use in creating a [`ReversibleIterator`] from `before` and `after` timestamps should one not
 /// already be present
 pub fn iterator_from_before_or_after<I: BaseId<Output = I> + Validate>(
@@ -157,7 +176,7 @@ pub fn apply_pagination<
 #[derive(Serialize)]
 pub struct EmptyResponse {}
 
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Serialize, Deserialize, Clone, JsonSchema)]
 #[serde(rename_all = "camelCase")]
 pub struct ListResponse<T: Clone> {
     pub data: Vec<T>,
@@ -274,7 +293,8 @@ fn validation_errors(
         .collect()
 }
 
-#[derive(Debug, Clone, Copy, Default)]
+#[derive(Debug, Clone, Copy, Default, OperationIo)]
+#[aide(input_with = "axum::extract::Json<T>", json_schema)]
 pub struct ValidatedJson<T>(pub T);
 
 #[async_trait]
@@ -353,6 +373,12 @@ impl<T> Deref for ValidatedQuery<T> {
     }
 }
 
+impl<T: JsonSchema> OperationInput for ValidatedQuery<T> {
+    fn operation_input(ctx: &mut aide::gen::GenContext, operation: &mut aide::openapi::Operation) {
+        axum::extract::Query::<T>::operation_input(ctx, operation)
+    }
+}
+
 /// This struct is slower than Query. Only use this if we need to pass arrays.
 #[derive(Debug)]
 pub struct MessageListFetchOptions {
@@ -399,6 +425,8 @@ where
     }
 }
 
+impl OperationInput for MessageListFetchOptions {}
+
 pub async fn api_not_implemented() -> Result<()> {
     Err(HttpError::not_implemented(None, None).into())
 }
@@ -423,6 +451,17 @@ pub fn validate_no_control_characters_unrequired(
     }
 }
 
+pub fn openapi_tag(tag: &'static str) -> impl FnOnce(TransformPathItem) -> TransformPathItem {
+    |op| op.tag(tag)
+}
+
+pub fn get_unix_timestamp() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap()
+        .as_secs()
+}
+
 #[cfg(test)]
 mod tests {
     use validator::Validate;
diff --git a/server/svix-server/src/v1/utils/patch.rs b/server/svix-server/src/v1/utils/patch.rs
index ba421d5ad..ef6c1326b 100644
--- a/server/svix-server/src/v1/utils/patch.rs
+++ b/server/svix-server/src/v1/utils/patch.rs
@@ -3,6 +3,7 @@
 
 //! Module defining utilites for PATCH requests focused mostly around non-required field types.
 
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use validator::Validate;
 
@@ -180,6 +181,34 @@ where
     }
 }
 
+impl<T: JsonSchema> JsonSchema for UnrequiredField<T> {
+    fn is_referenceable() -> bool {
+        false
+    }
+
+    fn schema_name() -> String {
+        format!("Unrequired_{}", T::schema_name())
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        Option::<T>::json_schema(gen)
+    }
+}
+
+impl<T: JsonSchema> JsonSchema for UnrequiredNullableField<T> {
+    fn is_referenceable() -> bool {
+        false
+    }
+
+    fn schema_name() -> String {
+        format!("UnrequiredNullable_{}", T::schema_name())
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        Option::<T>::json_schema(gen)
+    }
+}
+
 /// Macro that simplifies updating a field on an [`ActiveModel`] for use in a [`ModelIn`]
 /// implementation. This macro expands to setting the field when the [`Option`] is `Some`, but
 /// performs no operation in the case it is `None`.
diff --git a/server/svix-server/src/wait_for.rs b/server/svix-server/src/wait_for.rs
index 4c4637c14..2a434e313 100644
--- a/server/svix-server/src/wait_for.rs
+++ b/server/svix-server/src/wait_for.rs
@@ -16,13 +16,22 @@ pub async fn wait_for_dsn(
     let sleep = tokio::time::sleep(Duration::from_secs(wait_for_seconds));
     tokio::pin!(sleep);
 
+    const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(3);
+    const RETRY_WAIT: Duration = Duration::from_millis(500);
+
     loop {
         tokio::select! {
-            res = tokio::net::TcpStream::connect((host.to_string(), port)) => {
-                if res.is_err() {
-                    continue;
-                } else {
-                    break;
+            // Attempt the connection with a timeout
+            res = tokio::time::timeout(ATTEMPT_TIMEOUT, tokio::net::TcpStream::connect((host.to_string(), port))) => {
+                match res {
+                    // Connection attempt succeeded
+                    Ok(Ok(_)) => break,
+                    // Connection failed before the timeout was reached _or_ timed out
+                    Ok(Err(_)) | Err(_) => {
+                        tracing::debug!("{dependency_name} connection attempt failed, retrying in {RETRY_WAIT:?}...");
+                        tokio::time::sleep(RETRY_WAIT).await;
+                        continue;
+                    },
                 }
             }
 
diff --git a/server/svix-server/src/worker.rs b/server/svix-server/src/worker.rs
index b86198c5e..88160acae 100644
--- a/server/svix-server/src/worker.rs
+++ b/server/svix-server/src/worker.rs
@@ -1,53 +1,66 @@
 // SPDX-FileCopyrightText: © 2022 Svix Authors
-// SPDX-License-Identifier: MIT
+// SPDX-Licensepub(crate) -Identifier: MIT
 
 use crate::cfg::Configuration;
 
+use crate::core::cache::{kv_def, Cache, CacheBehavior, CacheKey, CacheValue};
 use crate::core::cryptography::Encryption;
-use crate::core::operational_webhooks::EndpointDisabledEvent;
+use crate::core::message_app::{CreateMessageApp, CreateMessageEndpoint};
+use crate::core::operational_webhooks::{
+    EndpointDisabledEvent, MessageAttemptEvent, OperationalWebhook, OperationalWebhookSender,
+};
 use crate::core::types::{
-    ApplicationId, ApplicationUid, EndpointId, EndpointSecretInternal, EndpointSecretType,
-    MessageUid, OrganizationId, RetrySchedule,
+    ApplicationId, ApplicationUid, BaseId, EndpointHeaders, EndpointId, EndpointSecretInternal,
+    EndpointSecretType, EventTypeName, MessageAttemptId, MessageAttemptTriggerType, MessageId,
+    MessageStatus, MessageUid, OrganizationId, RetrySchedule,
 };
-use crate::core::{
-    cache::{kv_def, Cache, CacheBehavior, CacheKey, CacheValue},
-    message_app::{CreateMessageApp, CreateMessageEndpoint},
-    operational_webhooks::{MessageAttemptEvent, OperationalWebhook, OperationalWebhookSender},
-    types::{
-        BaseId, EndpointHeaders, EventTypeName, MessageAttemptId, MessageAttemptTriggerType,
-        MessageId, MessageStatus,
-    },
+use crate::core::webhook_http_client::{
+    Error as WebhookClientError, RequestBuilder, WebhookClient,
 };
 use crate::db::models::{endpoint, eventtype, message, messageattempt, messagedestination};
-use crate::error::Result;
+use crate::error::{Error, ErrorType, HttpError, Result};
 use crate::queue::{
     MessageTask, MessageTaskBatch, QueueTask, TaskQueueConsumer, TaskQueueProducer,
 };
-use crate::{ctx, err_generic};
+use crate::v1::utils::get_unix_timestamp;
+use crate::{ctx, err_cache, err_generic, err_validation};
+
 use chrono::Utc;
 
 use futures::future;
+use http::{HeaderValue, StatusCode, Version};
+use ipnet::IpNet;
+use lazy_static::lazy_static;
 use p256::ecdsa::{signature, Signature};
 use rand::Rng;
-use reqwest::header::{HeaderMap, HeaderName};
-use sea_orm::{entity::prelude::*, ActiveValue::Set, DatabaseConnection, EntityTrait};
-use serde::{Deserialize, Serialize};
-use svix_ksuid::{KsuidLike, KsuidMs};
-use tokio::time::{sleep, Duration};
 
-use std::{
-    str::FromStr,
-    sync::{atomic::Ordering, Arc},
+use sea_orm::prelude::DateTimeUtc;
+use sea_orm::{
+    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, TryIntoModel,
 };
+use serde::{Deserialize, Serialize};
+use tokio::time::sleep;
+use tracing::Instrument;
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+pub type CaseSensitiveHeaderMap = HashMap<String, HeaderValue>;
 
 // The maximum variation from the retry schedule when applying jitter to a resent webhook event in
 // percent deviation
 const JITTER_DELTA: f32 = 0.2;
+const OVERLOAD_PENALTY_SECS: u64 = 60;
 
 const USER_AGENT: &str = concat!("Svix-Webhooks/", env!("CARGO_PKG_VERSION"));
+
 /// Send the MessageAttemptFailingEvent after exceeding this number of failed attempts
 const OP_WEBHOOKS_SEND_FAILING_EVENT_AFTER: usize = 4;
 
+const RESPONSE_MAX_SIZE: usize = 20000;
+
 /// A simple struct noting the context of the wrapped [`DateTimeUtc`]. This struct is returned when
 /// you are to disable disable an endpoint. This is optionally returned by [`process_failure_cache`]
 /// which is to be called after all retry events are exhausted.
@@ -66,8 +79,18 @@ pub struct FailureCacheValue {
 kv_def!(FailureCacheKey, FailureCacheValue, "SVIX_FAILURE_CACHE");
 
 impl FailureCacheKey {
-    pub fn new(app_id: &ApplicationId, endp_id: &EndpointId) -> FailureCacheKey {
-        FailureCacheKey(format!("_{app_id}_{endp_id}"))
+    pub fn new(
+        org_id: &OrganizationId,
+        app_id: &ApplicationId,
+        endp_id: &EndpointId,
+    ) -> FailureCacheKey {
+        FailureCacheKey(format!(
+            "{}_{}_{}_{}",
+            Self::PREFIX_CACHE,
+            org_id,
+            app_id,
+            endp_id
+        ))
     }
 }
 
@@ -95,16 +118,16 @@ impl RetryScheduleCacheKey {
 ///
 /// If the key value pair does not already exist in the cache, indicating that the endpoint never
 /// stopped responding, no operation is performed.
-async fn process_success_cache(
+#[tracing::instrument(skip_all)]
+async fn process_endpoint_success(
     cache: &Cache,
     app_id: &ApplicationId,
-    endp_id: &EndpointId,
+    org_id: &OrganizationId,
+    endp: &CreateMessageEndpoint,
 ) -> Result<()> {
-    let key = FailureCacheKey::new(app_id, endp_id);
+    let key = FailureCacheKey::new(org_id, app_id, &endp.id);
 
-    cache.delete(&key).await.map_err(|e| err_generic!(e))?;
-
-    Ok(())
+    cache.delete(&key).await.map_err(|e| err_cache!(e))
 }
 
 /// Called upon endpoint failure. Returns whether to disable the endpoint based on the time of first
@@ -118,15 +141,15 @@ async fn process_success_cache(
 ///
 /// All cache values are set with an expiration time greater thah the grace period, so occasional
 /// failures will not cause an endpoint to be disabled.
-async fn process_failure_cache(
+#[tracing::instrument(skip_all)]
+async fn process_endpoint_failure(
     cache: &Cache,
-
     app_id: &ApplicationId,
-    endp_id: &EndpointId,
-
+    org_id: &OrganizationId,
+    endp: &CreateMessageEndpoint,
     disable_in: Duration,
 ) -> Result<Option<EndpointDisableInfo>> {
-    let key = FailureCacheKey::new(app_id, endp_id);
+    let key = FailureCacheKey::new(org_id, app_id, &endp.id);
     let now = Utc::now();
 
     // If it already exists in the cache, see if the grace preiod has already elapsed
@@ -208,150 +231,161 @@ fn generate_msg_headers(
     whitelabel_headers: bool,
     configured_headers: Option<&EndpointHeaders>,
     _endpoint_url: &str,
-) -> HeaderMap {
-    let mut headers = HeaderMap::new();
-    let id = msg_id.0.parse().expect("Error parsing message id");
+) -> Result<CaseSensitiveHeaderMap> {
+    let mut headers = CaseSensitiveHeaderMap::new();
+    let id_hdr = msg_id
+        .0
+        .parse()
+        .map_err(|_| err_generic!("Error parsing message id".to_string()))?;
     let timestamp = timestamp
         .to_string()
         .parse()
-        .expect("Error parsing message timestamp");
+        .map_err(|_| err_generic!("Error parsing message timestamp".to_string()))?;
     let signatures_str = signatures
         .parse()
-        .expect("Error parsing message signatures");
+        .map_err(|_| err_generic!("Error parsing message signatures".to_string()))?;
     if whitelabel_headers {
-        headers.insert("webhook-id", id);
-        headers.insert("webhook-timestamp", timestamp);
-        headers.insert("webhook-signature", signatures_str);
+        headers.insert("webhook-id".to_owned(), id_hdr);
+        headers.insert("webhook-timestamp".to_owned(), timestamp);
+        headers.insert("webhook-signature".to_owned(), signatures_str);
     } else {
-        headers.insert("svix-id", id);
-        headers.insert("svix-timestamp", timestamp);
-        headers.insert("svix-signature", signatures_str);
+        headers.insert("svix-id".to_owned(), id_hdr);
+        headers.insert("svix-timestamp".to_owned(), timestamp);
+        headers.insert("svix-signature".to_owned(), signatures_str);
     }
-
+    headers.insert(
+        "user-agent".to_owned(),
+        USER_AGENT.to_string().parse().unwrap(),
+    );
+    headers.insert(
+        "content-type".to_owned(),
+        "application/json".parse().unwrap(),
+    );
     if let Some(configured_headers) = configured_headers {
         for (k, v) in &configured_headers.0 {
-            if let (Ok(k), Ok(v)) = (HeaderName::from_str(k), v.parse()) {
-                headers.insert(k, v);
-            } else {
-                tracing::error!("Invalid HeaderName or HeaderValues for `{}: {}`", k, v);
+            match v.parse() {
+                Ok(v) => {
+                    headers.insert(k.clone(), v);
+                }
+                Err(e) => {
+                    tracing::error!("Invalid HeaderValue {}: {}", v, e);
+                }
             }
         }
     }
 
-    headers
+    Ok(headers)
 }
 
-#[derive(Clone, Copy)]
+#[derive(Clone)]
 struct WorkerContext<'a> {
-    task_id: &'a str,
     cfg: &'a Configuration,
-    db: &'a DatabaseConnection,
     cache: &'a Cache,
+    db: &'a DatabaseConnection,
     queue_tx: &'a TaskQueueProducer,
     op_webhook_sender: &'a OperationalWebhookSender,
 }
 
-struct DispatchExtraIds<'a> {
-    org_id: &'a OrganizationId,
-    app_uid: Option<&'a ApplicationUid>,
-    msg_uid: Option<&'a MessageUid>,
-    event_type_name: &'a EventTypeName,
+struct FailedDispatch(messageattempt::ActiveModel, Error);
+struct SuccessfulDispatch(messageattempt::ActiveModel);
+
+#[allow(clippy::large_enum_variant)]
+enum IncompleteDispatch {
+    Pending(PendingDispatch),
+    #[allow(dead_code)]
+    Failed(FailedDispatch),
 }
 
-/// Dispatches one webhook
-#[tracing::instrument(
-    skip_all,
-    fields(
-        task_id = task_id,
-        org_id = org_id.0.as_str(),
-        endp_id = msg_task.endpoint_id.0.as_str(),
-        msg_id = msg_task.msg_id.0.as_str()
-    )
-    level = "error"
-)]
-async fn dispatch(
-    WorkerContext {
-        task_id,
-        cache,
-        cfg,
-        db,
-        queue_tx,
-        op_webhook_sender,
+struct PendingDispatch {
+    method: http::Method,
+    url: String,
+    headers: CaseSensitiveHeaderMap,
+    payload: String,
+    request_timeout: u64,
+    created_at: DateTimeUtc,
+}
+
+enum CompletedDispatch {
+    Failed(FailedDispatch),
+    Successful(SuccessfulDispatch),
+}
+
+#[tracing::instrument(skip_all)]
+async fn prepare_dispatch(
+    WorkerContext { cfg, .. }: &WorkerContext<'_>,
+    DispatchContext {
+        msg_task,
+        payload,
+        endp,
         ..
-    }: WorkerContext<'_>,
-    msg_task: MessageTask,
-    DispatchExtraIds {
-        org_id,
-        app_uid,
-        msg_uid,
-        event_type_name,
-    }: DispatchExtraIds<'_>,
-    body: String,
-    endp: CreateMessageEndpoint,
-) -> Result<()> {
-    tracing::trace!("Dispatch: {} {}", &msg_task.msg_id, &endp.id);
+    }: DispatchContext<'_>,
+) -> Result<IncompleteDispatch> {
+    let attempt_created_at = Utc::now();
 
-    let now = Utc::now();
     let headers = {
         let keys = endp.valid_signing_keys();
 
         let signatures = sign_msg(
             &cfg.encryption,
-            now.timestamp(),
-            &body,
+            attempt_created_at.timestamp(),
+            payload,
             &msg_task.msg_id,
             &keys,
         );
 
-        let mut headers = generate_msg_headers(
-            now.timestamp(),
+        generate_msg_headers(
+            attempt_created_at.timestamp(),
             &msg_task.msg_id,
             signatures,
             cfg.whitelabel_headers,
             endp.headers.as_ref(),
             &endp.url,
-        );
-        headers.insert("user-agent", USER_AGENT.to_string().parse().unwrap());
-        headers.insert("content-type", "application/json".parse().unwrap());
-        headers
+        )?
     };
 
-    let client = reqwest::Client::builder()
-        .redirect(reqwest::redirect::Policy::none())
-        .build()
-        .expect("Invalid reqwest Client configuration");
-    let res = client
-        .post(&endp.url)
-        .headers(headers)
-        .timeout(Duration::from_secs(cfg.worker_request_timeout as u64))
-        .body(body)
-        .send()
-        .await;
-
-    let msg_dest = ctx!(
-        messagedestination::Entity::secure_find_by_msg(msg_task.msg_id.clone())
-            .filter(messagedestination::Column::EndpId.eq(endp.id.clone()))
-            .one(db)
-            .await
-    )?
-    .ok_or_else(|| err_generic!("Msg dest not found {} {}", msg_task.msg_id, endp.id))?;
+    Ok(IncompleteDispatch::Pending(PendingDispatch {
+        method: http::Method::POST,
+        url: endp.url.clone(),
+        headers,
+        payload: payload.to_owned(),
+        request_timeout: cfg.worker_request_timeout as _,
+        created_at: attempt_created_at,
+    }))
+}
 
-    if (msg_dest.status != MessageStatus::Pending && msg_dest.status != MessageStatus::Sending)
-        && (msg_task.trigger_type != MessageAttemptTriggerType::Manual)
-    {
-        // TODO: it happens when this message destination is "resent". This leads to 2 queue tasks with the same message destination
-        tracing::warn!(
-            "MessageDestination {} is not pending (it's {:?}).",
-            msg_dest.id,
-            msg_dest.status
-        );
-        return Ok(());
-    }
+#[tracing::instrument(skip_all)]
+async fn make_http_call(
+    DispatchContext { msg_task, endp, .. }: DispatchContext<'_>,
+    PendingDispatch {
+        method,
+        url,
+        headers,
+        payload,
+        request_timeout,
+        created_at,
+    }: PendingDispatch,
+    msg_dest: &messagedestination::Model,
+    whitelist_subnets: &Option<Arc<Vec<IpNet>>>,
+) -> Result<CompletedDispatch> {
+    let client = WebhookClient::new(
+        whitelist_subnets.clone(),
+        Some(Arc::new(vec!["backend".to_owned()])),
+    );
+    let req = RequestBuilder::new()
+        .method(method)
+        .uri_str(&url)
+        .map_err(|_| err_validation!("URL is invalid".to_owned()))?
+        .headers(headers)
+        .body(payload.into(), HeaderValue::from_static("application/json"))
+        .version(Version::HTTP_11)
+        .timeout(Duration::from_secs(request_timeout))
+        .build()
+        .map_err(|e| err_generic!(e))?;
 
     let attempt = messageattempt::ActiveModel {
         // Set both ID and created_at to the same timestamp
-        id: Set(MessageAttemptId::new(now.into(), None)),
-        created_at: Set(now.into()),
+        id: Set(MessageAttemptId::new(created_at.into(), None)),
+        created_at: Set(created_at.into()),
         msg_id: Set(msg_task.msg_id.clone()),
         endp_id: Set(endp.id.clone()),
         msg_dest_id: Set(msg_dest.id.clone()),
@@ -360,7 +394,8 @@ async fn dispatch(
         trigger_type: Set(msg_task.trigger_type),
         ..Default::default()
     };
-    let attempt = match res {
+
+    match client.execute(req).await {
         Ok(res) => {
             let status_code = res.status().as_u16() as i16;
             let status = if res.status().is_success() {
@@ -368,214 +403,351 @@ async fn dispatch(
             } else {
                 MessageStatus::Fail
             };
-            let http_error = res.error_for_status_ref().err();
 
-            let attempt = match res.bytes().await {
-                Ok(bytes) => {
-                    let body = bytes_to_string(bytes);
+            let http_error = if !res.status().is_success() {
+                Some(WebhookClientError::FailureStatus(res.status()))
+            } else {
+                None
+            };
 
-                    messageattempt::ActiveModel {
-                        response_status_code: Set(status_code),
-                        response: Set(body),
-                        status: Set(status),
-                        ..attempt
-                    }
+            let body = match hyper::body::to_bytes(res.into_body()).await {
+                Ok(bytes) if bytes.len() > RESPONSE_MAX_SIZE => {
+                    bytes_to_string(bytes.slice(..RESPONSE_MAX_SIZE))
                 }
+                Ok(bytes) => bytes_to_string(bytes),
+                Err(err) => format!("Error reading response body: {err}"),
+            };
 
-                Err(err) => {
-                    tracing::warn!("Error reading response body: {}", err);
-                    messageattempt::ActiveModel {
-                        response_status_code: Set(status_code),
-                        response: Set(format!("failed to read response body: {err}")),
-                        status: Set(status),
-                        ..attempt
-                    }
-                }
+            let attempt = messageattempt::ActiveModel {
+                response_status_code: Set(status_code),
+                response: Set(body),
+                status: Set(status),
+                ..attempt
             };
 
             match http_error {
-                Some(err) => Err((attempt, err)),
-                None => Ok(attempt),
+                Some(err) => Ok(CompletedDispatch::Failed(FailedDispatch(
+                    attempt,
+                    err_generic!(err.to_string()),
+                ))),
+                None => Ok(CompletedDispatch::Successful(SuccessfulDispatch(attempt))),
             }
         }
-
-        Err(err) => {
-            let attempt = messageattempt::ActiveModel {
+        Err(err) => Ok(CompletedDispatch::Failed(FailedDispatch(
+            messageattempt::ActiveModel {
                 response_status_code: Set(0),
                 response: Set(err.to_string()),
                 status: Set(MessageStatus::Fail),
-
                 ..attempt
-            };
-            Err((attempt, err))
-        }
+            },
+            err.into(),
+        ))),
+    }
+}
+
+#[tracing::instrument(skip_all, fields(response_code, msg_dest_id=msg_dest.id.0))]
+async fn handle_successful_dispatch(
+    WorkerContext { cache, db, .. }: &WorkerContext<'_>,
+    DispatchContext {
+        org_id,
+        endp,
+        app_id,
+        ..
+    }: DispatchContext<'_>,
+    SuccessfulDispatch(mut attempt): SuccessfulDispatch,
+    msg_dest: messagedestination::Model,
+) -> Result<()> {
+    attempt.ended_at = Set(Some(Utc::now().into()));
+    let attempt = ctx!(attempt.insert(*db).await)?;
+
+    let msg_dest = messagedestination::ActiveModel {
+        status: Set(MessageStatus::Success),
+        next_attempt: Set(None),
+        ..msg_dest.into()
     };
+    let _msg_dest = ctx!(msg_dest.update(*db).await)?;
 
-    match attempt {
-        Ok(attempt) => {
-            let _attempt = ctx!(attempt.insert(db).await)?;
+    process_endpoint_success(cache, app_id, org_id, endp).await?;
 
-            let msg_dest = messagedestination::ActiveModel {
-                status: Set(MessageStatus::Success),
-                next_attempt: Set(None),
-                ..msg_dest.into()
-            };
-            let msg_dest = ctx!(msg_dest.update(db).await)?;
+    tracing::Span::current().record("response_code", attempt.response_status_code);
+    tracing::info!("Webhook success.");
 
-            process_success_cache(cache, &msg_task.app_id, &msg_task.endpoint_id).await?;
+    Ok(())
+}
 
-            tracing::trace!("Worker success: {} {}", &msg_dest.id, &endp.id,);
-        }
-        Err((attempt, err)) => {
-            let attempt = ctx!(attempt.insert(db).await)?;
-
-            let retry_schedule_override =
-                get_retry_schedule_override(db, cache, org_id, event_type_name)
-                    .await?
-                    .map(|x| x.0);
-            let retry_schedule = retry_schedule_override
-                .as_deref()
-                .unwrap_or(&cfg.retry_schedule);
-
-            let attempt_count = msg_task.attempt_count as usize;
-            if msg_task.trigger_type == MessageAttemptTriggerType::Manual {
-                tracing::debug!("Manual retry failed");
-            } else if attempt_count < retry_schedule.len() {
-                tracing::debug!(
-                    "Worker failure retrying for attempt {}: {} {} {}",
-                    attempt_count,
-                    err,
-                    &msg_dest.id,
-                    &endp.id
-                );
+fn calculate_retry_delay(duration: Duration, err: Error) -> Duration {
+    let duration = if matches!(err.typ, ErrorType::Timeout(_))
+        || matches!(err.typ, ErrorType::Http(HttpError { status, .. }) if status == StatusCode::TOO_MANY_REQUESTS)
+    {
+        std::cmp::max(duration, Duration::from_secs(OVERLOAD_PENALTY_SECS))
+    } else {
+        duration
+    };
+    // Apply jitter with a maximum variation of JITTER_DELTA
+    rand::thread_rng()
+        .gen_range(duration.mul_f32(1.0 - JITTER_DELTA)..=duration.mul_f32(1.0 + JITTER_DELTA))
+}
 
-                let duration = retry_schedule[attempt_count];
+#[tracing::instrument(skip_all, fields(response_code, msg_dest_id=msg_dest.id.0))]
+async fn handle_failed_dispatch(
+    WorkerContext {
+        db,
+        cache,
+        op_webhook_sender,
+        cfg,
+        queue_tx,
+        ..
+    }: &WorkerContext<'_>,
+    DispatchContext {
+        org_id,
+        app_id,
+        app_uid,
+        msg_uid,
+        endp,
+        msg_task,
+        event_type_name,
+        ..
+    }: DispatchContext<'_>,
+    FailedDispatch(mut attempt, err): FailedDispatch,
+    msg_dest: messagedestination::Model,
+) -> Result<()> {
+    attempt.ended_at = Set(Some(Utc::now().into()));
+    let attempt = ctx!(attempt.insert(*db).await)?;
+
+    tracing::Span::current().record("response_code", attempt.response_status_code);
+    tracing::info!("Webhook failure.");
+
+    let retry_schedule_override = get_retry_schedule_override(db, cache, org_id, event_type_name)
+        .await?
+        .map(|x| x.0);
+    let retry_schedule = retry_schedule_override
+        .as_deref()
+        .unwrap_or(&cfg.retry_schedule);
+
+    let attempt_count = msg_task.attempt_count as usize;
+    if msg_task.trigger_type == MessageAttemptTriggerType::Manual {
+        tracing::debug!("Manual retry failed");
+        Ok(())
+    } else if attempt_count < retry_schedule.len() {
+        tracing::debug!(
+            "Worker failure retrying for attempt {}: {} {} {}",
+            attempt_count,
+            err,
+            &msg_dest.id,
+            &endp.id
+        );
 
-                // Apply jitter with a maximum variation of JITTER_DELTA
-                let duration = rand::thread_rng().gen_range(
-                    duration.mul_f32(1.0 - JITTER_DELTA)..duration.mul_f32(1.0 + JITTER_DELTA),
+        let retry_delay = calculate_retry_delay(retry_schedule[attempt_count], err);
+        let next_attempt_time =
+            Utc::now() + chrono::Duration::from_std(retry_delay).expect("Error parsing duration");
+        let msg_dest = messagedestination::ActiveModel {
+            next_attempt: Set(Some(next_attempt_time.into())),
+            ..msg_dest.into()
+        };
+        let _msg_dest = ctx!(msg_dest.update(*db).await)?;
+
+        if attempt_count == (OP_WEBHOOKS_SEND_FAILING_EVENT_AFTER - 1) {
+            if let Err(e) = op_webhook_sender
+                .send_operational_webhook(
+                    org_id,
+                    OperationalWebhook::MessageAttemptFailing(MessageAttemptEvent {
+                        app_id: app_id.clone(),
+                        app_uid: app_uid.cloned(),
+                        endpoint_id: msg_task.endpoint_id.clone(),
+                        msg_id: msg_task.msg_id.clone(),
+                        msg_event_id: msg_uid.cloned(),
+                        last_attempt: attempt.into(),
+                    }),
+                )
+                .await
+            {
+                tracing::error!(
+                    "Failed sending MessageAttemptFailing Operational Webhook: {}",
+                    e
                 );
+            }
+        }
+        queue_tx
+            .send(
+                QueueTask::MessageV1(MessageTask {
+                    attempt_count: msg_task.attempt_count + 1,
+                    ..msg_task.clone()
+                }),
+                Some(retry_delay),
+            )
+            .await?;
 
-                let msg_dest = messagedestination::ActiveModel {
-                    next_attempt: Set(Some(
-                        (Utc::now()
-                            + chrono::Duration::from_std(duration)
-                                .expect("Error parsing duration"))
-                        .into(),
-                    )),
-                    ..msg_dest.into()
-                };
-                let _msg_dest = ctx!(msg_dest.update(db).await)?;
-
-                if attempt_count == OP_WEBHOOKS_SEND_FAILING_EVENT_AFTER {
-                    op_webhook_sender
-                        .send_operational_webhook(
-                            org_id,
-                            OperationalWebhook::MessageAttemptFailing(MessageAttemptEvent {
-                                app_id: &msg_task.app_id,
-                                app_uid,
-                                endpoint_id: &msg_task.endpoint_id,
-                                msg_id: &msg_task.msg_id,
-                                msg_event_id: msg_uid,
-                                last_attempt: (&attempt).into(),
-                            }),
-                        )
-                        .await?;
-                }
+        Ok(())
+    } else {
+        tracing::debug!(
+            "Worker failure attempts exhausted: {} {} {}",
+            err,
+            &msg_dest.id,
+            &endp.id
+        );
 
-                queue_tx
-                    .send(
-                        QueueTask::MessageV1(MessageTask {
-                            attempt_count: msg_task.attempt_count + 1,
-                            ..msg_task
-                        }),
-                        Some(duration),
+        let msg_dest = messagedestination::ActiveModel {
+            status: Set(MessageStatus::Fail),
+            next_attempt: Set(None),
+            ..msg_dest.into()
+        };
+        let _msg_dest = ctx!(msg_dest.update(*db).await)?;
+
+        // Send common operational webhook
+        op_webhook_sender
+            .send_operational_webhook(
+                org_id,
+                OperationalWebhook::MessageAttemptExhausted(MessageAttemptEvent {
+                    app_id: app_id.clone(),
+                    app_uid: app_uid.cloned(),
+                    endpoint_id: msg_task.endpoint_id.clone(),
+                    msg_id: msg_task.msg_id.clone(),
+                    msg_event_id: msg_uid.cloned(),
+                    last_attempt: attempt.into(),
+                }),
+            )
+            .await?;
+
+        match process_endpoint_failure(
+            cache,
+            app_id,
+            org_id,
+            endp,
+            cfg.endpoint_failure_disable_after,
+        )
+        .await?
+        {
+            None => Ok(()),
+
+            Some(EndpointDisableInfo { first_failure_at }) => {
+                let endp = ctx!(
+                    endpoint::Entity::secure_find_by_id(
+                        msg_task.app_id.clone(),
+                        msg_task.endpoint_id.clone(),
                     )
-                    .await?;
-            } else {
-                tracing::debug!(
-                    "Worker failure attempts exhausted: {} {} {}",
-                    err,
-                    &msg_dest.id,
-                    &endp.id
-                );
-                let msg_dest = messagedestination::ActiveModel {
-                    status: Set(MessageStatus::Fail),
-                    next_attempt: Set(None),
-                    ..msg_dest.into()
+                    .one(*db)
+                    .await
+                )?
+                .ok_or_else(|| {
+                    err_generic!("Endpoint not found {} {}", app_id, &msg_task.endpoint_id)
+                })?;
+
+                let endp = endpoint::ActiveModel {
+                    disabled: Set(true),
+                    first_failure_at: Set(Some(first_failure_at.into())),
+                    ..endp.into()
                 };
-                let _msg_dest = ctx!(msg_dest.update(db).await)?;
+                let _endp = ctx!(endp.update(*db).await)?;
 
-                // Send common operational webhook
+                // Send operational webhooks
                 op_webhook_sender
                     .send_operational_webhook(
                         org_id,
-                        OperationalWebhook::MessageAttemptExhausted(MessageAttemptEvent {
-                            app_id: &msg_task.app_id,
-                            app_uid,
-                            endpoint_id: &msg_task.endpoint_id,
-                            msg_id: &msg_task.msg_id,
-                            msg_event_id: msg_uid,
-                            last_attempt: (&attempt).into(),
+                        OperationalWebhook::EndpointDisabled(EndpointDisabledEvent {
+                            app_id: app_id.clone(),
+                            app_uid: app_uid.cloned(),
+                            endpoint_id: msg_task.endpoint_id.clone(),
+                            // TODO:
+                            endpoint_uid: None,
+                            fail_since: first_failure_at,
                         }),
                     )
-                    .await?;
-
-                match process_failure_cache(
-                    cache,
-                    &msg_task.app_id,
-                    &msg_task.endpoint_id,
-                    cfg.endpoint_failure_disable_after,
-                )
-                .await?
-                {
-                    None => {}
-
-                    Some(EndpointDisableInfo { first_failure_at }) => {
-                        // Send operational webhooks
-                        op_webhook_sender
-                            .send_operational_webhook(
-                                org_id,
-                                OperationalWebhook::EndpointDisabled(EndpointDisabledEvent {
-                                    app_id: &msg_task.app_id,
-                                    app_uid,
-                                    endpoint_id: &msg_task.endpoint_id,
-                                    // TODO:
-                                    endpoint_uid: None,
-                                    fail_since: first_failure_at,
-                                }),
-                            )
-                            .await?;
-
-                        // Disable endpoint in DB
-                        let endp = ctx!(
-                            endpoint::Entity::secure_find_by_id(
-                                msg_task.app_id.clone(),
-                                msg_task.endpoint_id.clone(),
-                            )
-                            .one(db)
-                            .await
-                        )?
-                        .ok_or_else(|| {
-                            err_generic!(
-                                "Endpoint not found {} {}",
-                                &msg_task.app_id,
-                                &msg_task.endpoint_id
-                            )
-                        })?;
-
-                        let endp = endpoint::ActiveModel {
-                            disabled: Set(true),
-                            first_failure_at: Set(Some(first_failure_at.into())),
-                            ..endp.into()
-                        };
-                        let _endp = ctx!(endp.update(db).await)?;
-                    }
-                }
+                    .await
             }
         }
     }
-    Ok(())
+}
+
+#[derive(Clone)]
+struct DispatchContext<'a> {
+    msg_task: &'a MessageTask,
+    payload: &'a str,
+    endp: &'a CreateMessageEndpoint,
+    org_id: &'a OrganizationId,
+    app_id: &'a ApplicationId,
+    app_uid: Option<&'a ApplicationUid>,
+    msg_uid: Option<&'a MessageUid>,
+    event_type_name: &'a EventTypeName,
+}
+
+/// Dispatches one webhook
+#[tracing::instrument(
+    skip_all,
+    level = "error",
+    fields(
+        endp_id = msg_task.endpoint_id.0.as_str(),
+    )
+)]
+async fn dispatch_message_task(
+    worker_context: &WorkerContext<'_>,
+    msg: &message::Model,
+    app: &CreateMessageApp,
+    msg_task: MessageTask,
+    payload: &str,
+    endp: CreateMessageEndpoint,
+    msg_dest: Option<messagedestination::Model>,
+) -> Result<()> {
+    let WorkerContext { cfg, db, .. } = worker_context;
+
+    tracing::trace!("Dispatch start");
+
+    let msg_dest = if let Some(msg_dest) = msg_dest {
+        msg_dest
+    } else {
+        ctx!(
+            messagedestination::Entity::secure_find_by_msg(msg_task.msg_id.clone())
+                .filter(messagedestination::Column::EndpId.eq(endp.id.clone()))
+                .one(*db)
+                .await
+        )?
+        .ok_or_else(|| err_generic!("Msg dest not found {} {}", msg_task.msg_id, endp.id))?
+    };
+
+    if (msg_dest.status != MessageStatus::Pending && msg_dest.status != MessageStatus::Sending)
+        && (msg_task.trigger_type != MessageAttemptTriggerType::Manual)
+    {
+        // TODO: it happens when this message destination is "resent". This leads to 2 queue tasks with the same message destination
+        tracing::warn!(
+            "MessageDestination {} is not pending (it's {:?}).",
+            msg_dest.id,
+            msg_dest.status
+        );
+        return Ok(());
+    }
+
+    let dispatch_context = DispatchContext {
+        msg_task: &msg_task,
+        payload,
+        endp: &endp,
+        org_id: &app.org_id,
+        app_id: &app.id,
+        app_uid: app.uid.as_ref(),
+        msg_uid: msg.uid.as_ref(),
+        event_type_name: &msg.event_type,
+    };
+
+    let dispatch = prepare_dispatch(worker_context, dispatch_context.clone()).await?;
+    let completed = match dispatch {
+        IncompleteDispatch::Pending(pending) => {
+            make_http_call(
+                dispatch_context.clone(),
+                pending,
+                &msg_dest,
+                &cfg.whitelist_subnets,
+            )
+            .await?
+        }
+        IncompleteDispatch::Failed(failed) => CompletedDispatch::Failed(failed),
+    };
+
+    match completed {
+        CompletedDispatch::Successful(success) => {
+            handle_successful_dispatch(worker_context, dispatch_context, success, msg_dest).await
+        }
+        CompletedDispatch::Failed(failed) => {
+            handle_failed_dispatch(worker_context, dispatch_context, failed, msg_dest).await
+        }
+    }
 }
 
 fn bytes_to_string(bytes: bytes::Bytes) -> String {
@@ -586,7 +758,7 @@ fn bytes_to_string(bytes: bytes::Bytes) -> String {
 }
 
 async fn get_retry_schedule_override(
-    db: &DbConn,
+    db: &DatabaseConnection,
     cache: &Cache,
     org_id: &OrganizationId,
     event_type_name: &EventTypeName,
@@ -621,15 +793,33 @@ async fn get_retry_schedule_override(
 }
 
 /// Manages preparation and execution of a QueueTask type
-#[tracing::instrument(skip_all, fields(task_id = worker_context.task_id), level = "error")]
-async fn process_task(worker_context: WorkerContext<'_>, queue_task: Arc<QueueTask>) -> Result<()> {
+#[tracing::instrument(skip_all, level = "error", fields(msg_id, app_id, org_id, instance_id, task_type=queue_task.to_string()))]
+async fn process_queue_task(
+    worker_context: WorkerContext<'_>,
+    queue_task: QueueTask,
+) -> Result<()> {
+    process_queue_task_inner(worker_context, queue_task)
+        .await
+        .map_err(|e| {
+            tracing::error!("{e}");
+            e
+        })
+}
+
+/// Manages preparation and execution of a QueueTask type
+async fn process_queue_task_inner(
+    worker_context: WorkerContext<'_>,
+    queue_task: QueueTask,
+) -> Result<()> {
     let WorkerContext { db, cache, .. }: WorkerContext<'_> = worker_context;
 
-    if *queue_task == QueueTask::HealthCheck {
+    if queue_task == QueueTask::HealthCheck {
         return Ok(());
     }
 
-    let (msg_id, trigger_type) = match &*queue_task {
+    let span = tracing::Span::current();
+
+    let (msg_id, trigger_type) = match &queue_task {
         QueueTask::MessageBatch(MessageTaskBatch {
             msg_id,
             trigger_type,
@@ -644,12 +834,21 @@ async fn process_task(worker_context: WorkerContext<'_>, queue_task: Arc<QueueTa
         QueueTask::HealthCheck => unreachable!(),
     };
 
+    span.record("msg_id", &msg_id.0);
+
     let msg = ctx!(message::Entity::find_by_id(msg_id.clone()).one(db).await)?
-        .ok_or_else(|| err_generic!("Unexpected: message doesn't exist {}", msg_id,))?;
-    let payload = msg.payload.as_ref().expect("Message payload is NULL");
+        .ok_or_else(|| err_generic!("Unexpected: message doesn't exist"))?;
+    let payload = msg
+        .payload
+        .as_ref()
+        .and_then(|value| serde_json::to_string(value).ok())
+        .ok_or_else(|| err_generic!("Message payload is NULL"))?;
 
-    let create_message_app = CreateMessageApp::layered_fetch(
-        cache.clone(),
+    span.record("app_id", &msg.app_id.0);
+    span.record("org_id", &msg.org_id.0);
+
+    let create_message_app = match CreateMessageApp::layered_fetch(
+        cache,
         db,
         None,
         msg.org_id.clone(),
@@ -657,14 +856,18 @@ async fn process_task(worker_context: WorkerContext<'_>, queue_task: Arc<QueueTa
         Duration::from_secs(30),
     )
     .await?
-    .ok_or_else(|| err_generic!("Application doesn't exist: {}", &msg.app_id))?;
-
-    let app_uid = create_message_app.uid.clone();
+    {
+        Some(create_message_app) => create_message_app,
+        None => {
+            tracing::info!("Application doesn't exist: {}", &msg.app_id);
+            return Ok(());
+        }
+    };
 
     let endpoints: Vec<CreateMessageEndpoint> = create_message_app
         .filtered_endpoints(*trigger_type, &msg.event_type, msg.channels.as_ref())
         .iter()
-        .filter(|endpoint| match &*queue_task {
+        .filter(|endpoint| match &queue_task {
             QueueTask::HealthCheck => unreachable!(),
             QueueTask::MessageV1(task) => task.endpoint_id == endpoint.id,
             QueueTask::MessageBatch(_) => true,
@@ -672,88 +875,167 @@ async fn process_task(worker_context: WorkerContext<'_>, queue_task: Arc<QueueTa
         .cloned()
         .collect();
 
-    // TODO: remove this section once destinations are obsolete
-    if matches!(*queue_task, QueueTask::MessageBatch(_)) {
-        let destinations = endpoints
-            .iter()
-            .map(|endpoint| messagedestination::ActiveModel {
-                msg_id: Set(msg.id.clone()),
-                endp_id: Set(endpoint.id.clone()),
-                next_attempt: Set(Some(Utc::now().into())),
-                status: Set(MessageStatus::Sending),
-                ..Default::default()
-            });
-        ctx!(
-            messagedestination::Entity::insert_many(destinations)
-                .exec(db)
-                .await
-        )?;
-    }
-
-    let org_id = &msg.org_id;
-    let msg_uid = &msg.uid;
-    let futures: Vec<_> = endpoints
-        .into_iter()
-        .map(|endpoint| {
-            let task = match &*queue_task {
-                QueueTask::MessageV1(task) => task.clone(),
-                QueueTask::MessageBatch(MessageTaskBatch {
-                    msg_id,
-                    app_id,
-                    trigger_type,
-                    ..
-                }) => MessageTask {
-                    msg_id: msg_id.clone(),
-                    app_id: app_id.clone(),
-                    endpoint_id: endpoint.id.clone(),
-                    attempt_count: 0,
-                    trigger_type: *trigger_type,
-                },
+    let futures: Vec<_> = match &queue_task {
+        QueueTask::HealthCheck => unreachable!(),
 
-                QueueTask::HealthCheck => unreachable!(),
+        QueueTask::MessageV1(task) => {
+            let endpoint = match endpoints.into_iter().next() {
+                Some(ep) => ep,
+                None => {
+                    return Ok(());
+                }
             };
 
-            let body = serde_json::to_string(&payload).expect("Error parsing message body");
-
-            dispatch(
-                worker_context,
-                task,
-                DispatchExtraIds {
-                    org_id,
-                    app_uid: app_uid.as_ref(),
-                    msg_uid: msg_uid.as_ref(),
-                    event_type_name: &msg.event_type,
-                },
-                body,
+            let destination = ctx!(
+                messagedestination::Entity::secure_find_by_msg(task.msg_id.clone())
+                    .filter(messagedestination::Column::EndpId.eq(endpoint.id.clone()))
+                    .one(db)
+                    .await
+            )?
+            .ok_or_else(|| {
+                err_generic!(format!(
+                    "MessageDestination not found for message {}",
+                    &task.msg_id
+                ))
+            })?;
+
+            vec![dispatch_message_task(
+                &worker_context,
+                &msg,
+                &create_message_app,
+                task.clone(),
+                &payload,
                 endpoint,
-            )
-        })
-        .collect();
+                Some(destination),
+            )]
+        }
+
+        QueueTask::MessageBatch(task) => {
+            let destinations: Vec<_> = endpoints
+                .iter()
+                .map(|endpoint| messagedestination::ActiveModel {
+                    msg_id: Set(msg.id.clone()),
+                    endp_id: Set(endpoint.id.clone()),
+                    next_attempt: Set(Some(Utc::now().into())),
+                    status: Set(MessageStatus::Sending),
+                    ..Default::default()
+                })
+                .collect();
+
+            ctx!(
+                messagedestination::Entity::insert_many(destinations.clone())
+                    .exec(db)
+                    .await
+            )?;
+
+            endpoints
+                .into_iter()
+                .zip(destinations)
+                .map(|(endpoint, destination)| {
+                    let task = MessageTask {
+                        msg_id: msg_id.clone(),
+                        app_id: task.app_id.clone(),
+                        endpoint_id: endpoint.id.clone(),
+                        attempt_count: 0,
+                        trigger_type: *trigger_type,
+                    };
+
+                    dispatch_message_task(
+                        &worker_context,
+                        &msg,
+                        &create_message_app,
+                        task,
+                        &payload,
+                        endpoint,
+                        destination.try_into_model().ok(),
+                    )
+                })
+                .collect()
+        }
+    };
 
     let join = future::join_all(futures).await;
 
     let errs: Vec<_> = join.iter().filter(|x| x.is_err()).collect();
     if !errs.is_empty() {
-        return Err(err_generic!(
-            "Some dispatches failed unexpectedly: {:?}",
-            errs
-        ));
+        return Err(err_generic!(format!(
+            "Some dispatches failed unexpectedly: {errs:?}"
+        )));
     }
 
     Ok(())
 }
 
+lazy_static! {
+    pub static ref LAST_QUEUE_POLL: AtomicU64 = get_unix_timestamp().into();
+}
+
+async fn update_last_poll_time() {
+    LAST_QUEUE_POLL.swap(get_unix_timestamp(), Ordering::Relaxed);
+}
+
 /// Listens on the message queue for new tasks
-pub async fn worker_loop(
+#[allow(clippy::too_many_arguments)]
+pub async fn queue_handler(
     cfg: &Configuration,
-    pool: &DatabaseConnection,
     cache: Cache,
+    db: DatabaseConnection,
     queue_tx: TaskQueueProducer,
     mut queue_rx: TaskQueueConsumer,
     op_webhook_sender: OperationalWebhookSender,
 ) -> Result<()> {
+    static NUM_WORKERS: AtomicUsize = AtomicUsize::new(0);
+
+    let task_limit = cfg.worker_max_tasks;
+    if task_limit == 0 {
+        tracing::info!("Worker concurrent task limit: unlimited");
+    } else {
+        tracing::info!("Worker concurrent task limit: {}", task_limit);
+    }
+
+    tokio::spawn(
+        async move {
+            let mut interval = tokio::time::interval(Duration::from_millis(500));
+            loop {
+                interval.tick().await;
+                let num_workers = NUM_WORKERS.load(Ordering::Relaxed);
+                if num_workers > 0 {
+                    tracing::info!("{} active workers", num_workers);
+                }
+            }
+        }
+        .instrument(tracing::error_span!(
+            "worker_monitor",
+            instance_id = tracing::field::Empty
+        )),
+    );
+
     loop {
+        if task_limit > 0 {
+            let num_workers = NUM_WORKERS.load(Ordering::Relaxed);
+            if num_workers > task_limit.into() {
+                tokio::time::sleep(Duration::from_millis(100)).await;
+                continue;
+            }
+        }
+
         if crate::SHUTTING_DOWN.load(Ordering::SeqCst) {
+            tokio::join!(async move {
+                let mut interval = tokio::time::interval(Duration::from_millis(500));
+                loop {
+                    interval.tick().await;
+                    let num_workers = NUM_WORKERS.load(Ordering::Relaxed);
+                    if num_workers > 0 {
+                        tracing::info!(
+                            "{} active workers, waiting to shut down worker.",
+                            num_workers
+                        );
+                    } else {
+                        tracing::info!("No active workers, shutting down worker.");
+                        break;
+                    }
+                }
+            });
             break;
         }
 
@@ -761,42 +1043,52 @@ pub async fn worker_loop(
             Ok(batch) => {
                 for delivery in batch {
                     let cfg = cfg.clone();
-                    let pool = pool.clone();
                     let cache = cache.clone();
+                    let db = db.clone();
                     let queue_tx = queue_tx.clone();
                     let queue_task = delivery.task.clone();
                     let op_webhook_sender = op_webhook_sender.clone();
 
                     tokio::spawn(async move {
-                        let task_id = KsuidMs::new(None, None).to_string();
+                        NUM_WORKERS.fetch_add(1, Ordering::Relaxed);
                         let worker_context = WorkerContext {
-                            task_id: &task_id,
                             cfg: &cfg,
-                            db: &pool,
+                            db: &db,
                             cache: &cache,
-                            queue_tx: &queue_tx,
                             op_webhook_sender: &op_webhook_sender,
+                            queue_tx: &queue_tx,
                         };
 
-                        if let Err(err) = process_task(worker_context, queue_task).await {
-                            tracing::error!("Error executing task: {}", err);
-                            queue_tx
-                                .nack(delivery)
-                                .await
-                                .expect("Error sending 'nack' to Redis after task execution error");
-                        } else {
-                            queue_tx.ack(delivery).await.expect(
-                                "Error sending 'ack' to Redis after successful task execution",
+                        let queue_task =
+                            Arc::try_unwrap(queue_task).unwrap_or_else(|arc| (*arc).clone());
+                        if process_queue_task(worker_context, queue_task)
+                            .await
+                            .is_err()
+                        {
+                            if let Err(err) = queue_tx.nack(delivery).await {
+                                tracing::error!(
+                                    "Error sending 'nack' to Redis after task execution error: {}",
+                                    err
+                                );
+                            }
+                        } else if let Err(err) = queue_tx.ack(delivery).await {
+                            tracing::error!(
+                                "Error sending 'ack' to Redis after successful task execution: {}",
+                                err
                             );
                         }
+
+                        NUM_WORKERS.fetch_sub(1, Ordering::Relaxed);
                     });
                 }
             }
             Err(err) => {
-                tracing::error!("Error receiving task: {}", err);
-                sleep(Duration::from_millis(10)).await;
+                tracing::error!("Error receiving task: {:?}", err);
+                sleep(tokio::time::Duration::from_millis(10)).await;
             }
         }
+
+        update_last_poll_time().await;
     }
 
     Ok(())
@@ -823,7 +1115,7 @@ mod tests {
 
     /// Utility function that returns the default set of headers before configurable header are
     /// accounted for
-    fn mock_headers() -> (HeaderMap, MessageId) {
+    fn mock_headers() -> (CaseSensitiveHeaderMap, MessageId) {
         let id = MessageId::new(None, None);
 
         let signatures = sign_msg(
@@ -842,23 +1134,21 @@ mod tests {
                 WHITELABEL_HEADERS,
                 None,
                 ENDPOINT_URL,
-            ),
+            )
+            .unwrap(),
             id,
         )
     }
 
-    // Tests configurable headers with a valid and an invalid header. The valid header pair should
-    // be included, while the invalid pair should be skipped.
     #[test]
-    fn test_generate_msg_headers_with_custom_headers() {
+    fn test_generate_msg_headers() {
         // The headers to be given to [`generate_msg_headers`]
         let mut headers = HashMap::new();
         headers.insert("test_key".to_owned(), "value".to_owned());
-        headers.insert("invälid_key".to_owned(), "value".to_owned());
 
         // The invalid key should be skipped over so it is not included in the expected
         let (mut expected, id) = mock_headers();
-        let _ = expected.insert("test_key", "value".parse().unwrap());
+        let _ = expected.insert("test_key".to_owned(), "value".parse().unwrap());
 
         let signatures = sign_msg(
             &Encryption::new_noop(),
@@ -875,7 +1165,8 @@ mod tests {
             WHITELABEL_HEADERS,
             Some(&EndpointHeaders(headers)),
             ENDPOINT_URL,
-        );
+        )
+        .unwrap();
 
         assert_eq!(expected, actual);
     }
@@ -910,7 +1201,8 @@ mod tests {
             WHITELABEL_HEADERS,
             None,
             ENDPOINT_URL,
-        );
+        )
+        .unwrap();
 
         assert_eq!(
             actual.get("svix-signature").unwrap(),
@@ -918,7 +1210,7 @@ mod tests {
         );
     }
 
-    // Tests asemmtric signing keys
+    // Tests asymmetric signing keys
     #[test]
     fn test_asymmetric_key_signing() {
         let timestamp = 1614265330;


Test patch:
diff --git a/server/run-tests.sh b/server/run-tests.sh
index b318e70a4..699fd7f1b 100755
--- a/server/run-tests.sh
+++ b/server/run-tests.sh
@@ -8,6 +8,7 @@ TEST_COMMAND="cargo test --all --all-features --all-targets"
 export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
 export SVIX_JWT_SECRET="test value"
 export SVIX_LOG_LEVEL="info"
+export SVIX_WHITELIST_SUBNETS="[127.0.0.1/32]"
 
 echo "*********** RUN 1 ***********"
 SVIX_QUEUE_TYPE="redis" \
diff --git a/server/svix-server/tests/e2e_attempt.rs b/server/svix-server/tests/e2e_attempt.rs
index fa0b4d27d..2dfe258fd 100644
--- a/server/svix-server/tests/e2e_attempt.rs
+++ b/server/svix-server/tests/e2e_attempt.rs
@@ -185,10 +185,7 @@ async fn test_list_attempts_by_endpoint() {
 #[tokio::test]
 async fn test_message_attempts() {
     let mut cfg = get_default_test_config();
-    cfg.retry_schedule = (0..2)
-        .into_iter()
-        .map(|_| Duration::from_millis(1))
-        .collect();
+    cfg.retry_schedule = (0..2).map(|_| Duration::from_millis(1)).collect();
 
     let (client, _jh) = start_svix_server_with_cfg(&cfg).await;
 
diff --git a/server/svix-server/tests/e2e_auth.rs b/server/svix-server/tests/e2e_auth.rs
index 8795f0335..2a8b02e2a 100644
--- a/server/svix-server/tests/e2e_auth.rs
+++ b/server/svix-server/tests/e2e_auth.rs
@@ -4,31 +4,13 @@
 
 use reqwest::StatusCode;
 
-use svix_server::{
-    core::types::ApplicationId,
-    v1::endpoints::{application::ApplicationOut, auth::DashboardAccessOut},
-};
+use svix_server::{core::types::ApplicationId, v1::endpoints::application::ApplicationOut};
 
 mod utils;
-use utils::{common_calls::application_in, start_svix_server, IgnoredResponse, TestClient};
-
-/// Accesses the dashboard-access endpoint and returns a new [`TestClient`] with an auth header set
-/// to the returned token.
-async fn dashboard_access(org_client: &TestClient, application_id: &ApplicationId) -> TestClient {
-    let resp: DashboardAccessOut = org_client
-        .post(
-            &format!("api/v1/auth/dashboard-access/{application_id}/"),
-            (),
-            StatusCode::OK,
-        )
-        .await
-        .unwrap();
-
-    let mut app_client = org_client.clone();
-    app_client.set_auth_header(resp.token);
-
-    app_client
-}
+use utils::{
+    common_calls::{app_portal_access, application_in},
+    start_svix_server, IgnoredResponse,
+};
 
 #[tokio::test]
 /// Users with application-level tokens should only be allowed to read the information related to
@@ -55,7 +37,7 @@ async fn test_restricted_application_access() {
         .unwrap()
         .id;
 
-    let client = dashboard_access(&client, &app_id).await;
+    let client = app_portal_access(&client, &app_id, Default::default()).await;
 
     // CREATE, UPDATE, DELETE, and LIST ops
     let _: IgnoredResponse = client
@@ -93,3 +75,28 @@ async fn test_restricted_application_access() {
         .await
         .unwrap();
 }
+
+#[tokio::test]
+async fn test_dashboard_access_without_body() {
+    let (client, _jh) = start_svix_server().await;
+
+    let app_id: ApplicationId = client
+        .post::<_, ApplicationOut>(
+            "api/v1/app/",
+            application_in("TEST_APP_NAME"),
+            StatusCode::CREATED,
+        )
+        .await
+        .unwrap()
+        .id;
+
+    // We just need to ensure we get an OK response without a body.
+    let _: IgnoredResponse = client
+        .post(
+            &format!("api/v1/auth/dashboard-access/{app_id}/"),
+            (),
+            StatusCode::OK,
+        )
+        .await
+        .unwrap();
+}
diff --git a/server/svix-server/tests/e2e_endpoint.rs b/server/svix-server/tests/e2e_endpoint.rs
index cccd38bf0..6162556ef 100644
--- a/server/svix-server/tests/e2e_endpoint.rs
+++ b/server/svix-server/tests/e2e_endpoint.rs
@@ -1,25 +1,33 @@
 // SPDX-FileCopyrightText: © 2022 Svix Authors
 // SPDX-License-Identifier: MIT
 
-use crate::utils::common_calls::metadata;
+use crate::utils::common_calls::{default_test_endpoint, metadata};
 use anyhow::Result;
-use chrono::Utc;
+use chrono::{DateTime, Utc};
 use ed25519_compact::Signature;
 use p256::ecdsa::signature::Verifier;
 use p256::ecdsa::{self, VerifyingKey};
-use reqwest::StatusCode;
-use sea_orm::{ConnectionTrait, DatabaseBackend, QueryResult, Statement};
+use reqwest::{StatusCode, Url};
+use sea_orm::{
+    ActiveModelBehavior, ActiveModelTrait, ConnectionTrait, DatabaseBackend, QueryResult, Set,
+    Statement,
+};
 use std::sync::Arc;
 use std::{
     collections::{HashMap, HashSet},
     time::Duration,
 };
+use svix_server::db::models::{message, messagedestination};
+use svix_server::v1::endpoints::endpoint::EndpointStatsOut;
 
 use serde::Deserialize;
 use svix::webhooks::Webhook;
 use svix_server::cfg::DefaultSignatureType;
 use svix_server::core::cryptography::AsymmetricKeyP256;
-use svix_server::core::types::{BaseId, EndpointSecretType, OrganizationId};
+use svix_server::core::types::{
+    BaseId, EndpointId, EndpointSecretType, MessageEndpointId, MessageId, MessageStatus,
+    OrganizationId,
+};
 use svix_server::{
     core::{
         cryptography::{AsymmetricKey, Encryption},
@@ -126,7 +134,7 @@ async fn test_patch() {
     // Assert that no other changes were made
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url/".to_owned());
     assert_eq!(out.ep.version, 1);
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -153,7 +161,7 @@ async fn test_patch() {
     // Assert that no other changes were made
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url/".to_owned());
     assert_eq!(out.ep.version, 1);
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -180,7 +188,7 @@ async fn test_patch() {
     // Assert that no other changes were made
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url/".to_owned());
     assert_eq!(out.ep.version, 1);
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -207,7 +215,7 @@ async fn test_patch() {
     // Assert that no other changes were made
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
-    assert_eq!(out.ep.url, "http://bad.url".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url/".to_owned());
     assert_eq!(out.ep.version, 1);
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -234,7 +242,7 @@ async fn test_patch() {
     // Assert that no other changes were made
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
-    assert_eq!(out.ep.url, "http://bad.url".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url/".to_owned());
     assert_eq!(out.ep.version, 1);
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -257,7 +265,7 @@ async fn test_patch() {
         .get::<EndpointOut>(&url, StatusCode::OK)
         .await
         .unwrap();
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     // Assert that no other changes were made
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
@@ -289,7 +297,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert!(!out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
     assert_eq!(out.ep.channels, None);
@@ -316,7 +324,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert_eq!(out.ep.version, 2);
     assert_eq!(out.ep.event_types_ids, None);
     assert_eq!(out.ep.channels, None);
@@ -362,7 +370,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert_eq!(out.ep.version, 2);
     assert!(out.ep.disabled);
     assert_eq!(out.ep.channels, None);
@@ -389,7 +397,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert_eq!(out.ep.version, 2);
     assert!(out.ep.disabled);
     assert_eq!(out.ep.channels, None);
@@ -421,7 +429,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert_eq!(out.ep.version, 2);
     assert!(out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -448,7 +456,7 @@ async fn test_patch() {
     assert_eq!(out.ep.description, "test".to_owned());
     assert_eq!(out.ep.rate_limit, None);
     assert_eq!(out.ep.uid, None);
-    assert_eq!(out.ep.url, "http://bad.url2".to_owned());
+    assert_eq!(out.ep.url, "http://bad.url2/".to_owned());
     assert_eq!(out.ep.version, 2);
     assert!(out.ep.disabled);
     assert_eq!(out.ep.event_types_ids, None);
@@ -461,11 +469,11 @@ async fn test_crud() {
     const APP_NAME_1: &str = "v1EndpointCrudTestApp1";
     const APP_NAME_2: &str = "v1EndpointCrudTestApp2";
 
-    const EP_URI_APP_1_EP_1_VER_1: &str = "http://v1EndpointCrudTestApp1Ep1Ver1.test";
-    const EP_URI_APP_1_EP_1_VER_2: &str = "http://v1EndpointCrudTestApp1Ep1Ver2.test";
-    const EP_URI_APP_1_EP_2: &str = "http://v1EndpointCrudTestApp1Ep2.test";
-    const EP_URI_APP_2_EP_1: &str = "http://v1EndpointCrudTestApp2Ep1.test";
-    const EP_URI_APP_2_EP_2: &str = "http://v1EndpointCrudTestApp2Ep2.test";
+    const EP_URI_APP_1_EP_1_VER_1: &str = "http://v1EndpointCrudTestApp1Ep1Ver1.test/foo";
+    const EP_URI_APP_1_EP_1_VER_2: &str = "http://v1EndpointCrudTestApp1Ep1Ver2.test/";
+    const EP_URI_APP_1_EP_2: &str = "http://v1EndpointCrudTestApp1Ep2.test/";
+    const EP_URI_APP_2_EP_1: &str = "http://v1EndpointCrudTestApp2Ep1.test/";
+    const EP_URI_APP_2_EP_2: &str = "http://v1EndpointCrudTestApp2Ep2.test/";
 
     let app_1 = create_test_app(&client, APP_NAME_1).await.unwrap().id;
     let app_2 = create_test_app(&client, APP_NAME_2).await.unwrap().id;
@@ -474,25 +482,25 @@ async fn test_crud() {
     let app_1_ep_1 = create_test_endpoint(&client, &app_1, EP_URI_APP_1_EP_1_VER_1)
         .await
         .unwrap();
-    assert_eq!(app_1_ep_1.ep.url, EP_URI_APP_1_EP_1_VER_1);
+    assert_eq!(app_1_ep_1.ep.url, EP_URI_APP_1_EP_1_VER_1.to_lowercase());
     assert_eq!(app_1_ep_1.ep.version, 1);
 
     let app_1_ep_2 = create_test_endpoint(&client, &app_1, EP_URI_APP_1_EP_2)
         .await
         .unwrap();
-    assert_eq!(app_1_ep_2.ep.url, EP_URI_APP_1_EP_2);
+    assert_eq!(app_1_ep_2.ep.url, EP_URI_APP_1_EP_2.to_lowercase());
     assert_eq!(app_1_ep_2.ep.version, 1);
 
     let app_2_ep_1 = create_test_endpoint(&client, &app_2, EP_URI_APP_2_EP_1)
         .await
         .unwrap();
-    assert_eq!(app_2_ep_1.ep.url, EP_URI_APP_2_EP_1);
+    assert_eq!(app_2_ep_1.ep.url, EP_URI_APP_2_EP_1.to_lowercase());
     assert_eq!(app_2_ep_1.ep.version, 1);
 
     let app_2_ep_2 = create_test_endpoint(&client, &app_2, EP_URI_APP_2_EP_2)
         .await
         .unwrap();
-    assert_eq!(app_2_ep_2.ep.url, EP_URI_APP_2_EP_2);
+    assert_eq!(app_2_ep_2.ep.url, EP_URI_APP_2_EP_2.to_lowercase());
     assert_eq!(app_2_ep_2.ep.version, 1);
 
     // READ
@@ -539,7 +547,7 @@ async fn test_crud() {
         )
         .await
         .unwrap();
-    assert_eq!(app_1_ep_1.ep.url, EP_URI_APP_1_EP_1_VER_2);
+    assert_eq!(app_1_ep_1.ep.url, EP_URI_APP_1_EP_1_VER_2.to_lowercase());
 
     // CONFIRM UPDATE
     assert_eq!(
@@ -842,10 +850,7 @@ async fn test_recovery_should_fail_if_start_time_too_old() {
 async fn test_recovery_expected_retry_counts() {
     let mut cfg = get_default_test_config();
 
-    cfg.retry_schedule = (0..2)
-        .into_iter()
-        .map(|_| Duration::from_millis(1))
-        .collect();
+    cfg.retry_schedule = (0..2).map(|_| Duration::from_millis(1)).collect();
 
     // total attempts for a failed message should be 1 (first attempt) + length of retry_schedule:
     let base_attempt_cnt = 1 + &cfg.retry_schedule.len();
@@ -1039,10 +1044,9 @@ async fn test_endpoint_rotate_signing_symmetric_and_asymmetric() {
     let secret_4 = EndpointSecret::Symmetric(base64::decode("TUdfVE5UMnZlci1TeWxOYXQtX1ZlTW1kLTRtMFdhYmEwanIxdHJvenRCbmlTQ2hFdzBnbHhFbWdFaTJLdzQwSA==").unwrap());
 
     let ep_in = EndpointIn {
-        url: receiver.endpoint.clone(),
-        version: 1,
+        url: Url::parse(&receiver.endpoint).unwrap(),
         key: Some(secret_1.clone()),
-        ..Default::default()
+        ..default_test_endpoint()
     };
 
     let endp = post_endpoint(&client, &app_id, ep_in.clone())
@@ -1156,11 +1160,7 @@ async fn test_endpoint_secret_config() {
 
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
-    let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
-        ..Default::default()
-    };
+    let ep_in = default_test_endpoint();
 
     let ep = post_endpoint(&client, &app_id, ep_in.clone())
         .await
@@ -1225,10 +1225,8 @@ async fn test_custom_endpoint_secret() {
     );
 
     let mut ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
         key: Some(secret_1.clone()),
-        ..Default::default()
+        ..default_test_endpoint()
     };
 
     let endp_1 = post_endpoint(&client, &app_id, ep_in.clone())
@@ -1287,11 +1285,7 @@ async fn test_endpoint_secret_encryption() {
 
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
-    let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
-        ..Default::default()
-    };
+    let ep_in = default_test_endpoint();
 
     let ep = post_endpoint(&client, &app_id, ep_in.clone())
         .await
@@ -1387,6 +1381,147 @@ async fn test_invalid_endpoint_secret() {
     }
 }
 
+fn new_message_dest_at_time(
+    timestamp: DateTime<Utc>,
+    status: MessageStatus,
+    endp_id: &EndpointId,
+    msg_id: &MessageId,
+) -> messagedestination::ActiveModel {
+    messagedestination::ActiveModel {
+        endp_id: Set(endp_id.clone()),
+        msg_id: Set(msg_id.clone()),
+        id: Set(MessageEndpointId::new(timestamp.into(), None)),
+        status: Set(status),
+        created_at: Set(timestamp.into()),
+        updated_at: Set(timestamp.into()),
+        ..ActiveModelTrait::default()
+    }
+}
+
+#[tokio::test]
+async fn test_endpoint_stats() {
+    let (client, _jh) = start_svix_server().await;
+
+    let app_id = create_test_app(&client, "app1").await.unwrap().id;
+
+    let endp_id = create_test_endpoint(&client, &app_id, "https://gabagool.deli")
+        .await
+        .unwrap()
+        .id;
+
+    let stats: EndpointStatsOut = client
+        .get(
+            &format!("api/v1/app/{app_id}/endpoint/{endp_id}/stats/"),
+            StatusCode::OK,
+        )
+        .await
+        .unwrap();
+
+    assert_eq!(stats.fail, 0);
+    assert_eq!(stats.success, 0);
+    assert_eq!(stats.pending, 0);
+    assert_eq!(stats.sending, 0);
+
+    let last_msg_time = {
+        // Create the relevant Stats records manually, otherwise
+        // it's difficult to test exact state of messagedestinations.
+
+        let cfg = get_default_test_config();
+        let db = Arc::new(cfg);
+        let db = svix_server::db::init_db(&db).await;
+
+        let now = Utc::now();
+
+        let msg = message::ActiveModel {
+            app_id: Set(app_id.clone()),
+            org_id: Set(OrganizationId::new(None, None)),
+            expiration: Set(Utc::now().into()),
+            event_type: Set(EventTypeName("test.ing".into())),
+            created_at: Set((now - chrono::Duration::minutes(65)).into()),
+            id: Set(MessageId::new(
+                (now - chrono::Duration::minutes(65)).into(),
+                None,
+            )),
+            ..message::ActiveModel::new()
+        }
+        .insert(&db)
+        .await
+        .unwrap();
+
+        new_message_dest_at_time(
+            now - chrono::Duration::minutes(60),
+            MessageStatus::Pending,
+            &endp_id,
+            &msg.id,
+        )
+        .insert(&db)
+        .await
+        .unwrap();
+
+        new_message_dest_at_time(
+            now - chrono::Duration::minutes(45),
+            MessageStatus::Pending,
+            &endp_id,
+            &msg.id,
+        )
+        .insert(&db)
+        .await
+        .unwrap();
+
+        new_message_dest_at_time(
+            now - chrono::Duration::minutes(30),
+            MessageStatus::Sending,
+            &endp_id,
+            &msg.id,
+        )
+        .insert(&db)
+        .await
+        .unwrap()
+        .created_at
+    };
+
+    let stats: EndpointStatsOut = client
+        .get(
+            &format!("api/v1/app/{app_id}/endpoint/{endp_id}/stats/"),
+            StatusCode::OK,
+        )
+        .await
+        .unwrap();
+
+    assert_eq!(stats.fail, 0);
+    assert_eq!(stats.success, 0);
+    assert_eq!(stats.pending, 2);
+    assert_eq!(stats.sending, 1);
+
+    let stats_filtered: EndpointStatsOut = client
+        .get(
+            &format!(
+                "api/v1/app/{app_id}/endpoint/{endp_id}/stats/?since={}&until={}",
+                urlencoding::encode(&last_msg_time.to_rfc3339()),
+                urlencoding::encode(&Utc::now().to_rfc3339()),
+            ),
+            StatusCode::OK,
+        )
+        .await
+        .unwrap();
+
+    assert_eq!(stats_filtered.fail, 0);
+    assert_eq!(stats_filtered.success, 0);
+    assert_eq!(stats_filtered.pending, 0);
+    assert_eq!(stats_filtered.sending, 1);
+
+    let _: IgnoredResponse = client
+        .get(
+            &format!(
+                "api/v1/app/{app_id}/endpoint/{endp_id}/stats/?since={}",
+                urlencoding::encode(&(Utc::now() - chrono::Duration::days(29)).to_rfc3339()),
+            ),
+            StatusCode::BAD_REQUEST,
+        )
+        .await
+        .unwrap();
+}
+
 /// We used to store the secret in the DB without a type marker, check loading those still works
 #[tokio::test]
 async fn test_legacy_endpoint_secret() {
@@ -1407,10 +1542,8 @@ async fn test_legacy_endpoint_secret() {
     let secret_1 = EndpointSecret::Symmetric(raw_key.clone());
 
     let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
         key: Some(secret_throwaway.clone()),
-        ..Default::default()
+        ..default_test_endpoint()
     };
 
     let endp_1 = post_endpoint(&client, &app_id, ep_in.clone())
@@ -1458,11 +1591,7 @@ async fn test_endpoint_secret_encryption_in_database() {
 
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
-    let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
-        ..Default::default()
-    };
+    let ep_in = default_test_endpoint();
 
     let ep = post_endpoint(&client, &app_id, ep_in.clone())
         .await
@@ -1483,11 +1612,7 @@ async fn test_endpoint_secret_encryption_in_database() {
 
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
-    let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
-        ..Default::default()
-    };
+    let ep_in = default_test_endpoint();
 
     let ep = post_endpoint(&client, &app_id, ep_in.clone())
         .await
@@ -1700,10 +1825,8 @@ async fn test_rate_limit() {
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
     let ep_in = EndpointIn {
-        url: "http://www.example.com".to_owned(),
-        version: 1,
         rate_limit: Some(100),
-        ..Default::default()
+        ..default_test_endpoint()
     };
 
     let endp = post_endpoint(&client, &app_id, ep_in.clone())
@@ -1763,10 +1886,9 @@ async fn test_msg_event_types_filter() {
             &client,
             &app_id,
             EndpointIn {
-                url: receiver.endpoint.to_owned(),
-                version: 1,
+                url: Url::parse(&receiver.endpoint).unwrap(),
                 event_types_ids: event_types,
-                ..Default::default()
+                ..default_test_endpoint()
             },
         )
         .await
@@ -1808,7 +1930,7 @@ async fn test_msg_channels_filter() {
 
     let app_id = create_test_app(&client, "app1").await.unwrap().id;
 
-    let receiver = TestReceiver::start(StatusCode::OK);
+    let _receiver = TestReceiver::start(StatusCode::OK);
 
     let ec = EventChannelSet(HashSet::from([EventChannel("tag1".to_owned())]));
 
@@ -1817,10 +1939,8 @@ async fn test_msg_channels_filter() {
             &client,
             &app_id,
             EndpointIn {
-                url: receiver.endpoint.to_owned(),
-                version: 1,
                 channels,
-                ..Default::default()
+                ..default_test_endpoint()
             },
         )
         .await
diff --git a/server/svix-server/tests/e2e_event_type.rs b/server/svix-server/tests/e2e_event_type.rs
index 560ca03dc..4cde72677 100644
--- a/server/svix-server/tests/e2e_event_type.rs
+++ b/server/svix-server/tests/e2e_event_type.rs
@@ -1,14 +1,20 @@
 // SPDX-FileCopyrightText: © 2022 Svix Authors
 // SPDX-License-Identifier: MIT
 
+use std::collections::HashSet;
+
 use reqwest::StatusCode;
 
 use svix_server::{
+    core::types::{ApplicationId, EventTypeName, FeatureFlag, FeatureFlagSet},
     db::models::eventtype::Schema,
     v1::{
-        endpoints::endpoint::EndpointOut,
-        endpoints::event_type::{EventTypeIn, EventTypeOut, RetryScheduleInOut},
-        endpoints::message::MessageOut,
+        endpoints::{
+            application::ApplicationOut,
+            endpoint::EndpointOut,
+            event_type::{EventTypeIn, EventTypeOut, RetryScheduleInOut},
+            message::MessageOut,
+        },
         utils::ListResponse,
     },
 };
@@ -17,10 +23,10 @@ mod utils;
 
 use utils::{
     common_calls::{
-        common_test_list, create_test_app, event_type_in, get_msg_attempt_list_and_assert_count,
-        message_in,
+        app_portal_access, application_in, common_test_list, create_test_app, event_type_in,
+        get_msg_attempt_list_and_assert_count, message_in,
     },
-    start_svix_server,
+    start_svix_server, IgnoredResponse,
 };
 
 #[tokio::test]
@@ -196,6 +202,96 @@ async fn test_event_type_create_read_list() {
     }));
 }
 
+#[tokio::test]
+async fn test_event_type_feature_flags() {
+    let (client, _jh) = start_svix_server().await;
+
+    let feature = FeatureFlag("foo-feature".into());
+    let another_feature = FeatureFlag("bar-feature".into());
+    let (features, other_features, union) = {
+        let mut s1 = HashSet::new();
+        s1.insert(feature.clone());
+        let mut s2 = HashSet::new();
+        s2.insert(another_feature);
+        let union: FeatureFlagSet = s1.union(&s2).cloned().collect();
+
+        (s1, s2, union)
+    };
+
+    let et: EventTypeOut = client
+        .post(
+            "api/v1/event-type/",
+            EventTypeIn {
+                name: EventTypeName("event-type-with-flag".to_owned()),
+                description: "test-event-description".to_owned(),
+                deleted: false,
+                schemas: None,
+                feature_flag: Some(feature),
+            },
+            StatusCode::CREATED,
+        )
+        .await
+        .unwrap();
+
+    let _: EventTypeOut = client
+        .post(
+            "api/v1/event-type/",
+            EventTypeIn {
+                name: EventTypeName("no-flag-event".to_owned()),
+                description: "test-event-description".to_owned(),
+                deleted: false,
+                schemas: None,
+                feature_flag: None,
+            },
+            StatusCode::CREATED,
+        )
+        .await
+        .unwrap();
+
+    let app: ApplicationId = client
+        .post::<_, ApplicationOut>(
+            "api/v1/app/",
+            application_in("TEST_APP_NAME"),
+            StatusCode::CREATED,
+        )
+        .await
+        .unwrap()
+        .id;
+
+    let path = format!("api/v1/event-type/{}/", et.name);
+
+    for (flag_set, should_see) in [
+        (FeatureFlagSet::default(), false),
+        (other_features, false),
+        (union.clone(), true),
+        (features.clone(), true),
+    ] {
+        let client = app_portal_access(&client, &app, flag_set).await;
+
+        let list: ListResponse<EventTypeOut> = client
+            .get("api/v1/event-type/", StatusCode::OK)
+            .await
+            .unwrap();
+
+        if should_see {
+            // If the client is expected to see both event types it should be able to retrieve it
+            let got_et: EventTypeOut = client.get(&path, StatusCode::OK).await.unwrap();
+            assert_eq!(et, got_et);
+
+            // ... and see it in the list.
+            assert_eq!(list.data.len(), 2);
+            assert!(list.data.contains(&et));
+        } else {
+            // If the client is not supposed to see it it shouldn't be able to retrieve it
+            let _: IgnoredResponse = client.get(&path, StatusCode::NOT_FOUND).await.unwrap();
+
+            // ... and it shouldn't be in the list.
+            assert_eq!(list.data.len(), 1);
+            assert!(!list.data.contains(&et));
+        };
+    }
+}
+
 #[tokio::test]
 async fn test_list() {
     let (client, _jh) = start_svix_server().await;
diff --git a/server/svix-server/tests/e2e_operational_webhooks.rs b/server/svix-server/tests/e2e_operational_webhooks.rs
index f85d60a2e..5b549c408 100644
--- a/server/svix-server/tests/e2e_operational_webhooks.rs
+++ b/server/svix-server/tests/e2e_operational_webhooks.rs
@@ -6,6 +6,7 @@ use std::{net::TcpListener, sync::Arc, time::Duration};
 use chrono::{DateTime, Utc};
 use http::StatusCode;
 
+use reqwest::Url;
 use serde::Deserialize;
 use svix_ksuid::KsuidLike;
 use svix_server::{
@@ -29,6 +30,8 @@ use utils::{
     get_default_test_config, IgnoredResponse, TestClient, TestReceiver,
 };
 
+use crate::utils::common_calls::default_test_endpoint;
+
 /// Sent when an endpoint has been automatically disabled after continuous failures.
 #[derive(Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
@@ -155,9 +158,8 @@ async fn test_endpoint_create_update_and_delete() {
             &format!("api/v1/app/{}/endpoint/", op_webhook_app.id),
             EndpointIn {
                 description: "TestOperationalWebhookEndpoint".to_owned(),
-                url: receiver.endpoint.clone(),
-                version: 1,
-                ..Default::default()
+                url: Url::parse(&receiver.endpoint).unwrap(),
+                ..default_test_endpoint()
             },
             StatusCode::CREATED,
         )
@@ -203,9 +205,9 @@ async fn test_endpoint_create_update_and_delete() {
             ),
             EndpointIn {
                 description: "Updated description".to_owned(),
-                url: receiver.endpoint,
+                url: Url::parse(&receiver.endpoint).unwrap(),
                 version: 2,
-                ..Default::default()
+                ..default_test_endpoint()
             },
             StatusCode::OK,
         )
@@ -273,10 +275,7 @@ async fn test_endpoint_create_update_and_delete() {
 async fn test_message_attempt_operational_webhooks() {
     let mut cfg = get_default_test_config();
 
-    cfg.retry_schedule = (0..5)
-        .into_iter()
-        .map(|_| Duration::from_millis(1))
-        .collect();
+    cfg.retry_schedule = (0..5).map(|_| Duration::from_millis(1)).collect();
 
     let (client_regular, client_op, org_id, _jh) = start_svix_server_with_operational_webhooks(cfg);
 
@@ -302,9 +301,8 @@ async fn test_message_attempt_operational_webhooks() {
             &format!("api/v1/app/{}/endpoint/", op_webhook_app.id),
             EndpointIn {
                 description: "TestOperationalWebhookEndpoint".to_owned(),
-                url: receiver.endpoint.clone(),
-                version: 1,
-                ..Default::default()
+                url: Url::parse(&receiver.endpoint).unwrap(),
+                ..default_test_endpoint()
             },
             StatusCode::CREATED,
         )
diff --git a/server/svix-server/tests/integ_webhook_http_client.rs b/server/svix-server/tests/integ_webhook_http_client.rs
new file mode 100644
index 000000000..afcc70db3
--- /dev/null
+++ b/server/svix-server/tests/integ_webhook_http_client.rs
@@ -0,0 +1,156 @@
+use std::{net::TcpListener, sync::Arc};
+
+use axum::extract::State;
+use http::{header::USER_AGENT, HeaderValue, Request, StatusCode, Version};
+use hyper::Body;
+use serde::{Deserialize, Serialize};
+use tokio::sync::mpsc;
+
+use svix_server::core::webhook_http_client::{Error, RequestBuilder, WebhookClient};
+
+pub struct TestReceiver {
+    pub uri: String,
+    pub jh: tokio::task::JoinHandle<()>,
+    pub req_recv: mpsc::Receiver<Request<Body>>,
+}
+
+#[derive(Clone)]
+struct TestAppState {
+    tx: mpsc::Sender<Request<Body>>,
+    response_status_code: StatusCode,
+}
+
+impl TestReceiver {
+    pub fn start(resp_code: StatusCode) -> Self {
+        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+        let uri = format!("http://{}/", listener.local_addr().unwrap());
+
+        let (tx, req_recv) = mpsc::channel(32);
+
+        let routes = axum::Router::new()
+            .route("/", axum::routing::any(test_receiver_route))
+            .with_state(TestAppState {
+                tx,
+                response_status_code: resp_code,
+            })
+            .into_make_service();
+
+        let jh = tokio::spawn(async move {
+            axum::Server::from_tcp(listener)
+                .unwrap()
+                .serve(routes)
+                .await
+                .unwrap();
+        });
+
+        TestReceiver { uri, jh, req_recv }
+    }
+}
+
+async fn test_receiver_route(
+    State(TestAppState {
+        ref tx,
+        response_status_code,
+    }): State<TestAppState>,
+    req: Request<Body>,
+) -> axum::http::StatusCode {
+    tx.send(req).await.unwrap();
+    response_status_code
+}
+
+#[derive(Deserialize, Serialize)]
+pub struct TestSerializable {
+    test: String,
+}
+
+#[ignore]
+#[tokio::test]
+async fn test_client_basic_operation() {
+    // Compares output to `reqwest`.
+
+    let our_client = WebhookClient::new(Some(Arc::new(vec!["127.0.0.1/0".parse().unwrap()])), None);
+    let reqwest_client = reqwest::Client::builder()
+        .redirect(reqwest::redirect::Policy::none())
+        .build()
+        .expect("Invalid reqwest Client configuration");
+
+    let mut receiver = TestReceiver::start(StatusCode::OK);
+
+    let our_req = RequestBuilder::new()
+        .uri_str(&receiver.uri)
+        .unwrap()
+        .json_body(TestSerializable {
+            test: "value".to_owned(),
+        })
+        .unwrap()
+        .version(Version::HTTP_11)
+        .build()
+        .unwrap();
+
+    let _resp = our_client.execute(our_req).await.unwrap();
+
+    let our_http_req = receiver.req_recv.recv().await.unwrap();
+
+    let _resp = reqwest_client
+        .post(&receiver.uri)
+        .header(
+            USER_AGENT,
+            HeaderValue::from_static(concat!("Svix-Webhooks/", env!("CARGO_PKG_VERSION"))),
+        )
+        .version(Version::HTTP_11)
+        .json(&TestSerializable {
+            test: "value".to_owned(),
+        })
+        .send()
+        .await
+        .unwrap();
+
+    let reqwest_http_req = receiver.req_recv.recv().await.unwrap();
+
+    assert_eq!(our_http_req.headers(), reqwest_http_req.headers());
+    assert_eq!(
+        hyper::body::to_bytes(our_http_req.into_body())
+            .await
+            .unwrap(),
+        hyper::body::to_bytes(reqwest_http_req.into_body())
+            .await
+            .unwrap()
+    );
+}
+
+#[tokio::test]
+async fn test_filtering() {
+    let our_client = WebhookClient::new(None, None);
+
+    let our_req = RequestBuilder::new()
+        .uri_str("http://127.0.0.1/")
+        .unwrap()
+        .json_body(TestSerializable {
+            test: "value".to_owned(),
+        })
+        .unwrap()
+        .version(Version::HTTP_11)
+        .build()
+        .unwrap();
+
+    assert!(matches!(
+        our_client.execute(our_req).await.unwrap_err(),
+        Error::BlockedIp
+    ));
+
+    let our_req = RequestBuilder::new()
+        .uri_str("http://localhost/")
+        .unwrap()
+        .json_body(TestSerializable {
+            test: "value".to_owned(),
+        })
+        .unwrap()
+        .version(Version::HTTP_11)
+        .build()
+        .unwrap();
+
+    assert!(matches!(
+        our_client.execute(our_req).await.unwrap_err(),
+        Error::BlockedIp
+    ));
+}
diff --git a/server/svix-server/tests/utils/common_calls.rs b/server/svix-server/tests/utils/common_calls.rs
index 436a83795..d89a4354b 100644
--- a/server/svix-server/tests/utils/common_calls.rs
+++ b/server/svix-server/tests/utils/common_calls.rs
@@ -5,15 +5,17 @@ use std::time::Duration;
 
 use anyhow::Result;
 use chrono::{DateTime, Utc};
-use reqwest::StatusCode;
+use reqwest::{StatusCode, Url};
 
 use serde::{de::DeserializeOwned, Serialize};
+use svix::api::DashboardAccessOut;
 use svix_server::{
-    core::types::{metadata::Metadata, ApplicationId, EventTypeName, MessageId},
+    core::types::{metadata::Metadata, ApplicationId, EventTypeName, FeatureFlagSet, MessageId},
     v1::{
         endpoints::{
             application::{ApplicationIn, ApplicationOut},
             attempt::MessageAttemptOut,
+            auth::AppPortalAccessIn,
             endpoint::{EndpointIn, EndpointOut, RecoverIn},
             event_type::EventTypeIn,
             message::{MessageIn, MessageOut},
@@ -47,11 +49,25 @@ pub async fn delete_test_app(client: &TestClient, id: ApplicationId) -> Result<I
 
 // Endpoint
 
-pub fn endpoint_in(url: &str) -> EndpointIn {
+pub fn default_test_endpoint() -> EndpointIn {
     EndpointIn {
-        url: url.to_owned(),
+        description: Default::default(),
+        rate_limit: Default::default(),
+        uid: Default::default(),
+        url: Url::parse("http://example.com").unwrap(),
         version: 1,
-        ..Default::default()
+        disabled: Default::default(),
+        event_types_ids: Default::default(),
+        channels: Default::default(),
+        key: Default::default(),
+        metadata: Default::default(),
+    }
+}
+
+pub fn endpoint_in(url: &str) -> EndpointIn {
+    EndpointIn {
+        url: Url::parse(url).unwrap(),
+        ..default_test_endpoint()
     }
 }
 
@@ -127,6 +143,7 @@ pub fn event_type_in(
         description: "test-event-description".to_owned(),
         deleted: false,
         schemas: schema.into().map(|s| serde_json::from_value(s).unwrap()),
+        feature_flag: None,
     })
 }
 
@@ -294,3 +311,25 @@ pub async fn get_msg_attempt_list_and_assert_count(
 pub fn metadata(s: &str) -> Metadata {
     serde_json::from_str::<Metadata>(s).unwrap()
 }
+
+/// Accesses the app-portal-access endpoint and returns a new [`TestClient`] with an auth header set
+/// to the returned token.
+pub async fn app_portal_access(
+    org_client: &TestClient,
+    application_id: &ApplicationId,
+    feature_flags: FeatureFlagSet,
+) -> TestClient {
+    let resp: DashboardAccessOut = org_client
+        .post(
+            &format!("api/v1/auth/app-portal-access/{application_id}/"),
+            AppPortalAccessIn { feature_flags },
+            StatusCode::OK,
+        )
+        .await
+        .unwrap();
+
+    let mut app_client = org_client.clone();
+    app_client.set_auth_header(resp.token);
+
+    app_client
+}
diff --git a/server/svix-server/tests/utils/mod.rs b/server/svix-server/tests/utils/mod.rs
index 94ea32adb..b91fe0c44 100644
--- a/server/svix-server/tests/utils/mod.rs
+++ b/server/svix-server/tests/utils/mod.rs
@@ -266,6 +266,13 @@ pub struct TestReceiver {
     pub response_status_code: Arc<Mutex<ResponseStatusCode>>,
 }
 
+#[derive(Clone)]
+pub struct TestAppState {
+    tx: mpsc::Sender<serde_json::Value>,
+    header_tx: mpsc::Sender<HeaderMap>,
+    response_status_code: Arc<Mutex<ResponseStatusCode>>,
+}
+
 #[derive(Clone)]
 pub struct ResponseStatusCode {
     pub status_code: axum::http::StatusCode,
@@ -288,9 +295,11 @@ impl TestReceiver {
                 "/",
                 axum::routing::post(test_receiver_route).get(test_receiver_route),
             )
-            .layer(axum::extract::Extension(tx))
-            .layer(axum::extract::Extension(header_tx))
-            .layer(axum::extract::Extension(response_status_code.clone()))
+            .with_state(TestAppState {
+                tx,
+                header_tx,
+                response_status_code: response_status_code.clone(),
+            })
             .into_make_service();
 
         let jh = tokio::spawn(async move {
@@ -316,11 +325,11 @@ impl TestReceiver {
 }
 
 async fn test_receiver_route(
-    axum::extract::Extension(ref tx): axum::extract::Extension<mpsc::Sender<serde_json::Value>>,
-    axum::extract::Extension(ref header_tx): axum::extract::Extension<mpsc::Sender<HeaderMap>>,
-    axum::extract::Extension(response_status_code): axum::extract::Extension<
-        Arc<Mutex<ResponseStatusCode>>,
-    >,
+    axum::extract::State(TestAppState {
+        tx,
+        header_tx,
+        response_status_code,
+    }): axum::extract::State<TestAppState>,
     headers: HeaderMap,
     axum::Json(json): axum::Json<serde_json::Value>,
 ) -> axum::http::StatusCode {
diff --git a/server/svix-server/tests/worker.rs b/server/svix-server/tests/worker.rs
index cbf229393..a59893b89 100644
--- a/server/svix-server/tests/worker.rs
+++ b/server/svix-server/tests/worker.rs
@@ -2,7 +2,7 @@
 //! As such they are included with integration tests for organizational purposes.
 use std::{net::TcpListener, sync::Arc, time::Duration};
 
-use axum::extract::Extension;
+use axum::extract::State;
 use http::StatusCode;
 use svix_server::v1::{
     endpoints::{attempt::MessageAttemptOut, endpoint::EndpointOut},
@@ -14,7 +14,6 @@ mod utils;
 use utils::{
     common_calls::{create_test_app, create_test_endpoint, create_test_message},
     get_default_test_config, run_with_retries, start_svix_server, start_svix_server_with_cfg,
-    ResponseStatusCode,
 };
 
 /// Runs a full Axum server with two endoints. The first endpoint redirects to the second endpoint
@@ -26,6 +25,12 @@ struct RedirectionVisitReportingReceiver {
     pub has_been_visited: Arc<Mutex<bool>>,
 }
 
+#[derive(Clone)]
+struct RedirectionVisitReportingState {
+    has_been_visited: Arc<Mutex<bool>>,
+    resp_with: axum::http::StatusCode,
+}
+
 impl RedirectionVisitReportingReceiver {
     pub fn start(resp_with: axum::http::StatusCode) -> Self {
         let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -43,8 +48,10 @@ impl RedirectionVisitReportingReceiver {
                 axum::routing::post(visit_reporting_receiver_route)
                     .get(visit_reporting_receiver_route),
             )
-            .layer(Extension(has_been_visited.clone()))
-            .layer(Extension(resp_with))
+            .with_state(RedirectionVisitReportingState {
+                has_been_visited: has_been_visited.clone(),
+                resp_with,
+            })
             .into_make_service();
 
         let jh = tokio::spawn(async move {
@@ -68,11 +75,13 @@ async fn redirecting_receiver_route() -> axum::response::Redirect {
 }
 
 async fn visit_reporting_receiver_route(
-    Extension(visited): Extension<Arc<Mutex<bool>>>,
-    Extension(status_code): Extension<Arc<Mutex<ResponseStatusCode>>>,
+    State(RedirectionVisitReportingState {
+        has_been_visited: visited,
+        resp_with,
+    }): State<RedirectionVisitReportingState>,
 ) -> StatusCode {
     *visited.lock().await = true;
-    status_code.lock().await.status_code
+    resp_with
 }
 
 // The worker has
@@ -224,6 +233,12 @@ struct SporadicallyFailingReceiver {
     pub jh: tokio::task::JoinHandle<()>,
 }
 
+#[derive(Clone)]
+struct SporadicallyFailingState {
+    count: Arc<Mutex<u8>>,
+    resp_with: (http::StatusCode, http::StatusCode),
+}
+
 impl SporadicallyFailingReceiver {
     pub fn start(resp_with: (http::StatusCode, http::StatusCode)) -> Self {
         let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -236,8 +251,7 @@ impl SporadicallyFailingReceiver {
                 "/",
                 axum::routing::post(sporadically_failing_route).get(sporadically_failing_route),
             )
-            .layer(Extension(count))
-            .layer(Extension(resp_with))
+            .with_state(SporadicallyFailingState { count, resp_with })
             .into_make_service();
 
         let jh = tokio::spawn(async move {
@@ -253,8 +267,10 @@ impl SporadicallyFailingReceiver {
 }
 
 async fn sporadically_failing_route(
-    Extension(count): Extension<Arc<Mutex<u8>>>,
-    Extension((resp_ok, resp_fail)): Extension<(StatusCode, StatusCode)>,
+    State(SporadicallyFailingState {
+        count,
+        resp_with: (resp_ok, resp_fail),
+    }): State<SporadicallyFailingState>,
 ) -> StatusCode {
     let mut count = count.lock().await;
     *count += 1;