booqable 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ module Booqable
2
+ module Middleware
3
+ module Auth
4
+ # Faraday middleware for OAuth2 authentication
5
+ #
6
+ # This middleware handles OAuth2 token-based authentication for HTTP requests.
7
+ # It automatically manages access tokens, refreshing them when expired, and
8
+ # adds the Bearer token to the Authorization header.
9
+ #
10
+ # @example Adding to Faraday middleware stack
11
+ # builder.use Booqable::Middleware::Auth::OAuth,
12
+ # client_id: "your_client_id",
13
+ # client_secret: "your_client_secret",
14
+ # api_endpoint: "https://company.booqable.com/api/v4/oauth/token",
15
+ # read_token: -> { stored_token },
16
+ # write_token: ->(token) { store_token(token) }
17
+ class OAuth < Base
18
+ # Initialize the OAuth authentication middleware
19
+ #
20
+ # @param app [#call] The next middleware in the Faraday stack
21
+ # @param options [Hash] Configuration options
22
+ # @option options [String] :client_id OAuth client ID
23
+ # @option options [String] :client_secret OAuth client secret
24
+ # @option options [String] :api_endpoint API endpoint URL for the OAuth provider
25
+ # @option options [Proc] :read_token Proc to read stored token
26
+ # @option options [Proc] :write_token Proc to store new token
27
+ # @raise [KeyError] If required options are not provided
28
+ def initialize(app, options = {})
29
+ super(app)
30
+
31
+ @client_id = options.fetch(:client_id)
32
+ @client_secret = options.fetch(:client_secret)
33
+ @api_endpoint = options.fetch(:api_endpoint)
34
+ @read_token = options.fetch(:read_token)
35
+ @write_token = options.fetch(:write_token)
36
+
37
+ @client = OAuthClient.new(
38
+ client_id: @client_id,
39
+ client_secret: @client_secret,
40
+ api_endpoint: @api_endpoint,
41
+ )
42
+ end
43
+
44
+ # Process the HTTP request and add OAuth authentication
45
+ #
46
+ # Retrieves the stored access token, refreshes it if expired, and adds
47
+ # it to the Authorization header. Then passes the request to the next
48
+ # middleware in the stack.
49
+ #
50
+ # @param env [Faraday::Env] The request environment
51
+ # @return [Faraday::Response] The response from the next middleware
52
+ def call(env)
53
+ @token = @client.get_access_token_from_hash(@read_token.call)
54
+
55
+ if @token.expired? || @token.expires_at.nil?
56
+ @token = refresh_token!
57
+ end
58
+
59
+ env.request_headers["Authorization"] ||= "Bearer #{@token.token}"
60
+
61
+ @app.call(env)
62
+ end
63
+
64
+ private
65
+
66
+ # Refresh the expired OAuth token
67
+ #
68
+ # Uses the refresh token to obtain a new access token and stores it
69
+ # using the configured write_token proc. Converts OAuth2 errors to
70
+ # Booqable errors for consistent error handling.
71
+ #
72
+ # @return [OAuth2::AccessToken] The new access token
73
+ # @raise [Booqable::Error] For OAuth-related errors
74
+ def refresh_token!
75
+ new_token = @token.refresh!
76
+
77
+ @write_token.call(new_token.to_hash)
78
+
79
+ new_token
80
+ rescue OAuth2::Error => e
81
+ response = e.response.response.env.to_h
82
+
83
+ Booqable::Error.from_response(response)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,157 @@
1
+ module Booqable
2
+ module Middleware
3
+ module Auth
4
+ # Faraday middleware for single-use JWT token authentication
5
+ #
6
+ # This middleware generates and adds single-use JWT tokens for authentication.
7
+ # Each token is unique per request and includes request-specific data like
8
+ # method, path, and body hash to prevent replay attacks.
9
+ #
10
+ # Supports multiple JWT algorithms: HS256 (HMAC), RS256 (RSA), and ES256 (ECDSA).
11
+ #
12
+ # For more info see: https://developers.booqable.com/#authentication-request-signing
13
+ #
14
+ # @example Adding to Faraday middleware stack
15
+ # builder.use Booqable::Middleware::Auth::SingleUse,
16
+ # single_use_token: "token_id",
17
+ # single_use_token_algorithm: "HS256",
18
+ # single_use_token_private_key: "secret_key",
19
+ # single_use_token_company_id: "company_uuid",
20
+ # single_use_token_user_id: "user_uuid",
21
+ # api_endpoint: "https://company.booqable.com/api/4"
22
+ class SingleUse < Base
23
+ # Token kind identifier for JWT header
24
+ KIND = "single_use"
25
+
26
+ # Default domain for issuer URL construction
27
+ BOOQABLE_DOMAIN = "booqable.com"
28
+
29
+ # Initialize the single-use token authentication middleware
30
+ #
31
+ # @param app [#call] The next middleware in the Faraday stack
32
+ # @param options [Hash] Configuration options
33
+ # @option options [String] :single_use_token Token identifier (kid)
34
+ # @option options [String] :single_use_token_algorithm JWT algorithm (HS256, RS256, ES256)
35
+ # @option options [Integer] :single_use_token_expiration_period Token expiration in seconds (default: 600)
36
+ # @option options [String] :single_use_token_company_id Company UUID for audience claim
37
+ # @option options [String] :single_use_token_user_id User UUID for subject claim
38
+ # @option options [String] :single_use_token_private_key Private key or secret for signing
39
+ # @option options [String] :api_endpoint API endpoint URL for issuer determination
40
+ # @raise [SingleUseTokenAlgorithmRequired] If algorithm is not provided
41
+ # @raise [SingleUseTokenCompanyIdRequired] If company ID is not provided
42
+ # @raise [SingleUseTokenUserIdRequired] If user ID is not provided
43
+ # @raise [PrivateKeyOrSecretRequired] If private key/secret is not provided
44
+ def initialize(app, options = {})
45
+ super(app)
46
+
47
+ @kid = options.fetch(:single_use_token)
48
+ @alg = options.fetch(:single_use_token_algorithm) || raise(SingleUseTokenAlgorithmRequired)
49
+ @exp = options.fetch(:single_use_token_expiration_period, Time.now.to_i + 10 * 60)
50
+ @aud = options.fetch(:single_use_token_company_id) || raise(SingleUseTokenCompanyIdRequired)
51
+ @sub = options.fetch(:single_use_token_user_id) || raise(SingleUseTokenUserIdRequired)
52
+ @raw_private_key = options.fetch(:single_use_token_private_key) || raise(PrivateKeyOrSecretRequired)
53
+ @api_endpoint = options.fetch(:api_endpoint, nil)
54
+
55
+ @private_key = private_key
56
+ end
57
+
58
+ # Process the HTTP request and add single-use token authentication
59
+ #
60
+ # Generates a unique JWT token for this specific request and adds it
61
+ # to the Authorization header. Then passes the request to the next
62
+ # middleware in the stack.
63
+ #
64
+ # @param env [Faraday::Env] The request environment
65
+ # @return [Faraday::Response] The response from the next middleware
66
+ def call(env)
67
+ env.request_headers["Authorization"] ||= "Bearer #{generate_token(env)}"
68
+
69
+ @app.call(env)
70
+ end
71
+
72
+ private
73
+
74
+ # Generate a JWT token for the current request
75
+ #
76
+ # @param env [Faraday::Env] The request environment
77
+ # @return [String] Encoded JWT token
78
+ def generate_token(env)
79
+ JWT.encode generate_payload(env), @private_key, @alg, headers
80
+ end
81
+
82
+ # Generate the JWT payload with request-specific claims
83
+ #
84
+ # @param env [Faraday::Env] The request environment
85
+ # @return [Hash] JWT payload with standard and custom claims
86
+ def generate_payload(env)
87
+ data = generate_data(env)
88
+
89
+ request_id = SecureRandom.respond_to?(:uuid_v4) ? SecureRandom.uuid_v4 : SecureRandom.uuid
90
+
91
+ {
92
+ alg: @alg,
93
+ iat: Time.now.to_i,
94
+ exp: Time.now.to_i + @exp,
95
+ aud: @aud,
96
+ sub: @sub,
97
+ iss: iss,
98
+ jti: "#{request_id}.#{data}"
99
+ }
100
+ end
101
+
102
+ # Generate JWT headers
103
+ #
104
+ # @return [Hash] JWT headers with key ID and kind
105
+ def headers
106
+ { kid: @kid, kind: KIND }
107
+ end
108
+
109
+ # Generate request-specific data hash for the JWT
110
+ #
111
+ # Creates a hash from the HTTP method, full path, and body content
112
+ # to ensure each token is unique to the specific request being made.
113
+ #
114
+ # @param env [Faraday::Env] The request environment
115
+ # @return [String] Base64-encoded SHA256 hash of request data
116
+ def generate_data(env)
117
+ request_method = env.method.to_s.upcase
118
+ fullpath = env.url.request_uri
119
+ body = env.body
120
+ encoded_body = body ? Base64.strict_encode64(::OpenSSL::Digest::SHA256.new(body).digest) : nil
121
+
122
+ Base64.strict_encode64(
123
+ ::OpenSSL::Digest::SHA256.new([ request_method, fullpath, encoded_body ].join(".")).digest
124
+ )
125
+ end
126
+
127
+ # Generate the issuer URL for the JWT
128
+ #
129
+ # @return [String] Issuer URL based on the API endpoint
130
+ def iss
131
+ "https://#{slug}.#{BOOQABLE_DOMAIN}"
132
+ end
133
+
134
+ # Extract the company slug from the API endpoint
135
+ #
136
+ # @return [String] Company identifier from the hostname
137
+ def slug
138
+ Addressable::URI.parse(@api_endpoint).host.split(".").first
139
+ end
140
+
141
+ # Parse the private key based on the configured algorithm
142
+ #
143
+ # @return [OpenSSL::PKey::EC, OpenSSL::PKey::RSA, String] Parsed private key
144
+ def private_key
145
+ case @alg
146
+ when "ES256"
147
+ OpenSSL::PKey::EC.new(@raw_private_key)
148
+ when "RS256"
149
+ OpenSSL::PKey::RSA.new(@raw_private_key)
150
+ when "HS256"
151
+ @raw_private_key
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booqable
4
+ module Middleware
5
+ Base = Faraday::Middleware
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ module Booqable
2
+ # Faraday response middleware for the Booqable client
3
+ module Middleware
4
+ # Faraday middleware that raises Booqable exceptions based on HTTP status codes
5
+ #
6
+ # This middleware automatically converts HTTP error responses into appropriate
7
+ # Booqable exception classes. It inspects the response status code and body to
8
+ # determine the specific error type and raises the corresponding exception.
9
+ #
10
+ # @example Adding to Faraday middleware stack
11
+ # builder.use Booqable::Middleware::RaiseError
12
+ #
13
+ # @see Booqable::Error.from_response
14
+ class RaiseError < Base
15
+ # Handle completed HTTP responses and raise exceptions for errors
16
+ #
17
+ # Called by Faraday after a response is received. Inspects the response
18
+ # and raises an appropriate Booqable exception if the status indicates an error.
19
+ # Successful responses are allowed to pass through unchanged.
20
+ #
21
+ # @param response [Faraday::Response] The HTTP response object
22
+ # @return [void]
23
+ # @raise [Booqable::Error] Various error subclasses based on status code
24
+ def on_complete(response)
25
+ Booqable::Error.from_response(response)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "oauth2"
4
+
5
+ module Booqable
6
+ # OAuth2 client for Booqable API authentication
7
+ #
8
+ # Provides OAuth2 authentication flow support for the Booqable API.
9
+ # Handles authorization code exchange for access tokens using the
10
+ # standard OAuth2 authorization code flow.
11
+ #
12
+ # @example Basic OAuth flow
13
+ # oauth_client = Booqable::OAuthClient.new(
14
+ # base_url: "https://demo.booqable.com",
15
+ # client_id: "your_client_id",
16
+ # client_secret: "your_client_secret",
17
+ # redirect_uri: "https://yourapp.com/callback"
18
+ # )
19
+ #
20
+ # # After user authorizes and returns with code
21
+ # token = oauth_client.get_token_from_code(params[:code])
22
+ # access_token = token.token
23
+ class OAuthClient
24
+ # OAuth2 token endpoint path
25
+ TOKEN_ENDPOINT = "/oauth/token"
26
+
27
+ # Initialize a new OAuth client
28
+ #
29
+ # @param base_url [String] Base URL of the Booqable instance (e.g., "https://demo.booqable.com")
30
+ # @param client_id [String] OAuth2 client identifier
31
+ # @param client_secret [String] OAuth2 client secret
32
+ # @param redirect_uri [String] OAuth2 redirect URI for authorization callback
33
+ def initialize(api_endpoint:, client_id:, client_secret:, redirect_uri: nil)
34
+ @client = OAuth2::Client.new(
35
+ client_id,
36
+ client_secret,
37
+ site: api_endpoint,
38
+ token_url: api_endpoint + TOKEN_ENDPOINT,
39
+ )
40
+ @redirect_uri = redirect_uri
41
+ end
42
+
43
+ # Exchange an authorization code for an access token
44
+ #
45
+ # Exchanges the authorization code received from the OAuth callback
46
+ # for an access token that can be used to make API requests.
47
+ #
48
+ # @param code [String] Authorization code from OAuth callback
49
+ # @param scope [String] OAuth scope to request (default: "full_access")
50
+ # @return [OAuth2::AccessToken] Access token object with token and refresh token
51
+ # @raise [OAuth2::Error] If the authorization code is invalid or expired
52
+ #
53
+ # @example
54
+ # token = oauth_client.get_token_from_code("auth_code_123")
55
+ # access_token = token.token
56
+ # refresh_token = token.refresh_token
57
+ def get_token_from_code(code, scope: "full_access")
58
+ @client.auth_code.get_token(code,
59
+ redirect_uri: @redirect_uri,
60
+ scope: scope,
61
+ grant_type: "authorization_code")
62
+ end
63
+
64
+ # Create an access token from a hash.
65
+ #
66
+ # @param hash [Hash] Hash containing access token data.
67
+ # @return [OAuth2::AccessToken] Access token object created from the hash.
68
+ def get_access_token_from_hash(hash)
69
+ OAuth2::AccessToken.from_hash(@client, hash)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booqable
4
+ # Rate limit information from API responses
5
+ #
6
+ # Contains rate limiting information extracted from HTTP response headers.
7
+ # This information is available when rate limit errors occur or can be
8
+ # accessed from successful responses to monitor API usage.
9
+ #
10
+ # @example Accessing rate limit info from an error
11
+ # begin
12
+ # Booqable.orders.list
13
+ # rescue Booqable::TooManyRequests => e
14
+ # rate_limit = e.context
15
+ # puts "Rate limit: #{rate_limit.remaining}/#{rate_limit.limit}"
16
+ # puts "Resets in: #{rate_limit.resets_in} seconds"
17
+ # end
18
+ #
19
+ # @!attribute [w] limit
20
+ # @return [Integer] Max tries per rate limit period
21
+ # @!attribute [w] remaining
22
+ # @return [Integer] Remaining tries per rate limit period
23
+ # @!attribute [w] resets_in
24
+ # @return [Integer] Number of seconds when rate limit resets
25
+ class RateLimit < Struct.new(:limit, :remaining, :resets_in)
26
+ # Extract rate limit information from HTTP response
27
+ #
28
+ # Parses standard rate limit headers from the HTTP response and creates
29
+ # a RateLimit object with the extracted information. Falls back to default
30
+ # values if headers are missing.
31
+ #
32
+ # @param response [#headers, #response_headers] HTTP response object
33
+ # @return [RateLimit] Rate limit information object
34
+ #
35
+ # @example
36
+ # rate_limit = Booqable::RateLimit.from_response(response)
37
+ # puts "#{rate_limit.remaining} requests remaining"
38
+ def self.from_response(response)
39
+ info = new
40
+ headers = response.headers if response.respond_to?(:headers) && !response.headers.nil?
41
+ headers ||= response.response_headers if response.respond_to?(:response_headers) && !response.response_headers.nil?
42
+ if headers
43
+ info.limit = (headers["X-RateLimit-Limit"] || 1).to_i
44
+ info.remaining = (headers["X-RateLimit-Remaining"] || 1).to_i
45
+ info.resets_in = (headers["X-RateLimit-Period"] || 1).to_i
46
+ end
47
+
48
+ info
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Booqable
4
+ # Generic resource proxy for API collections
5
+ #
6
+ # Provides a uniform interface for interacting with API resources using
7
+ # standard CRUD operations. Each resource proxy handles the JSON:API
8
+ # formatting and delegates HTTP requests to the underlying client.
9
+ #
10
+ # @example Working with orders
11
+ # orders = Booqable::ResourceProxy.new(client, "orders")
12
+ #
13
+ # # Of course, you can also just use Booqable.orders directly.
14
+ #
15
+ # # List all orders
16
+ # all_orders = orders.list
17
+ #
18
+ # # Find a specific order
19
+ # order = orders.find("123")
20
+ #
21
+ # # Create a new order
22
+ # new_order = orders.create(starts_at: "2024-01-01T00:00:00Z", stops_at: "2024-01-02T00:00:00Z", status: "draft")
23
+ #
24
+ # # Update an existing order
25
+ # updated_order = orders.update("123", status: "reserved")
26
+ #
27
+ # # Delete an existing order
28
+ # orders.delete("123")
29
+ class ResourceProxy
30
+ # Initialize a new resource proxy
31
+ #
32
+ # @param client [Booqable::Client] The client instance to use for requests
33
+ # @param resource_name [String, Symbol] The name of the resource (e.g., "orders", "customers")
34
+ def initialize(client, resource_name)
35
+ @client = client
36
+ @resource = resource_name.to_s
37
+ end
38
+
39
+ # List all resources
40
+ #
41
+ # Retrieves a collection of resources with optional filtering and pagination.
42
+ # Uses the JSON:API specification for query parameters.
43
+ #
44
+ # @param params [Hash] Query parameters for filtering, sorting, and pagination
45
+ # @option params [String] :include Related resources to include (e.g., "customer,items")
46
+ # @option params [Hash] :filter Filter criteria (e.g., { status: "active" })
47
+ # @option params [String] :sort Sort criteria (e.g., "created_at", "-updated_at")
48
+ # @option params [Hash] :page Pagination parameters (e.g., { number: 1, size: 25 })
49
+ # @return [Array, Enumerator] Collection of resources or enumerator for auto-pagination
50
+ #
51
+ # @example List orders with filters
52
+ # orders.list(
53
+ # include: "customer",
54
+ # filter: { status: "active" },
55
+ # sort: "-created_at"
56
+ # )
57
+ def list(params = {})
58
+ paginate @resource, params
59
+ end
60
+
61
+ # Find a specific resource by ID
62
+ #
63
+ # Retrieves a single resource by its unique identifier.
64
+ #
65
+ # @param id [String, Integer] The unique identifier of the resource
66
+ # @param params [Hash] Additional query parameters
67
+ # @option params [String] :include Related resources to include
68
+ # @return [Hash] The resource data
69
+ # @raise [Booqable::NotFound] If the resource doesn't exist
70
+ #
71
+ # @example Find an order by ID
72
+ # order = orders.find("123", include: "customer,items")
73
+ def find(id, params = {})
74
+ response = request :get, "#{@resource}/#{id}", params
75
+ response.data
76
+ end
77
+
78
+ # Create a new resource
79
+ #
80
+ # Creates a new resource with the provided attributes using JSON:API format.
81
+ #
82
+ # @param attrs [Hash] Attributes for the new resource
83
+ # @return [Hash] The created resource data
84
+ # @raise [Booqable::UnprocessableEntity] If validation fails
85
+ #
86
+ # @example Create a new order
87
+ # new_order = orders.create(
88
+ # starts_at: "2024-01-01T00:00:00Z",
89
+ # stops_at: "2024-01-02T00:00:00Z",
90
+ # status: "draft"
91
+ # )
92
+ def create(attrs = {})
93
+ response = request :post, @resource, { data: { type: @resource, attributes: attrs } }
94
+ response.data
95
+ end
96
+
97
+ # Update an existing resource
98
+ #
99
+ # Updates a resource with the provided attributes using JSON:API format.
100
+ #
101
+ # @param id [String, Integer] The unique identifier of the resource to update
102
+ # @param attrs [Hash] Attributes to update
103
+ # @return [Hash] The updated resource data
104
+ # @raise [Booqable::NotFound] If the resource doesn't exist
105
+ # @raise [Booqable::UnprocessableEntity] If validation fails
106
+ #
107
+ # @example Update an order
108
+ # updated_order = orders.update("123", status: "reserved")
109
+ def update(id, attrs = {})
110
+ response = request :put, "#{@resource}/#{id}", { data: { type: @resource, id: id, attributes: attrs } }
111
+ response.data
112
+ end
113
+
114
+ # Delete an existing resource
115
+ #
116
+ # Deletes a resource by its unique identifier.
117
+ #
118
+ # @param id [String, Integer] The unique identifier of the resource to delete
119
+ # @return [Object] The deleted resource data
120
+ # @raise [Booqable::NotFound] If the resource doesn't exist
121
+ #
122
+ # @example Delete an order
123
+ # deleted_order = orders.delete("123")
124
+ def delete(id)
125
+ response = request :delete, "#{@resource}/#{id}", {}
126
+ response.data
127
+ end
128
+
129
+ private
130
+
131
+ attr_reader :client
132
+
133
+ # Delegate request method to client
134
+ #
135
+ # @param args [Array] Arguments to pass to client.request
136
+ # @return [Object] Response from client.request
137
+ def request(...)
138
+ client.request(...)
139
+ end
140
+
141
+ # Delegate paginate method to client
142
+ #
143
+ # @param args [Array] Arguments to pass to client.paginate
144
+ # @return [Object] Response from client.paginate
145
+ def paginate(...)
146
+ client.paginate(...)
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,74 @@
1
+ [
2
+ { "app_carriers": "carriers" },
3
+ { "app_subscriptions": "subscriptions" },
4
+ { "app_payment_options": "payment_options" },
5
+ "authentication_methods",
6
+ "barcodes",
7
+ "bundles",
8
+ "bundle_items",
9
+ "clusters",
10
+ "collections",
11
+ "collection_items",
12
+ "collection_trees",
13
+ "companies",
14
+ "countries",
15
+ "coupons",
16
+ "customers",
17
+ "default_properties",
18
+ "delivery_distance_calculations",
19
+ "deposit_holds",
20
+ "documents",
21
+ "emails",
22
+ "email_templates",
23
+ "employees",
24
+ "employee_invitations",
25
+ "inventory_breakdowns",
26
+ "inventory_levels",
27
+ "invoice_finalizations",
28
+ "invoice_revisions",
29
+ "item_prices",
30
+ "items",
31
+ "lines",
32
+ "line_charge_suggestions",
33
+ "locations",
34
+ "notes",
35
+ "orders",
36
+ "order_delivery_rate_recalculations",
37
+ "order_delivery_rates",
38
+ "order_fulfillments",
39
+ "order_price_recalculations",
40
+ "order_status_transitions",
41
+ "payments",
42
+ "payment_authorizations",
43
+ "payment_charges",
44
+ "payment_methods",
45
+ "payment_refunds",
46
+ "photos",
47
+ "plannings",
48
+ "price_rules",
49
+ "price_rulesets",
50
+ "price_structures",
51
+ "price_tiles",
52
+ "products",
53
+ "product_groups",
54
+ "properties",
55
+ "provinces",
56
+ "settings",
57
+ "signatures",
58
+ "sortings",
59
+ "stock_adjustments",
60
+ "stock_items",
61
+ "stock_item_archivations",
62
+ "stock_item_plannings",
63
+ "stock_item_suggestions",
64
+ "tags",
65
+ "tax_categories",
66
+ "tax_rates",
67
+ "tax_regions",
68
+ "tax_values",
69
+ "transfers",
70
+ "user_invitations",
71
+ "users",
72
+ "webhook_endpoints",
73
+ "webhooks"
74
+ ]