kit-rb 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.githooks/pre-commit +21 -0
  3. data/.githooks/pre-push +6 -0
  4. data/CHANGELOG.md +83 -0
  5. data/README.md +58 -16
  6. data/docs/DESIGN.md +38 -14
  7. data/docs/TASKS.md +47 -0
  8. data/lib/kit/auth/api_key.rb +7 -0
  9. data/lib/kit/auth/credential.rb +22 -0
  10. data/lib/kit/auth/oauth.rb +7 -8
  11. data/lib/kit/connection.rb +53 -20
  12. data/lib/kit/errors.rb +52 -15
  13. data/lib/kit/oauth/token.rb +9 -0
  14. data/lib/kit/objects/account.rb +44 -3
  15. data/lib/kit/objects/broadcast_click.rb +17 -0
  16. data/lib/kit/objects/broadcast_stats.rb +30 -0
  17. data/lib/kit/objects/bulk_result.rb +39 -0
  18. data/lib/kit/objects/custom_field.rb +4 -2
  19. data/lib/kit/objects/email_stats.rb +25 -0
  20. data/lib/kit/objects/growth_stats.rb +20 -0
  21. data/lib/kit/objects/post.rb +4 -2
  22. data/lib/kit/objects/sequence_email.rb +5 -2
  23. data/lib/kit/objects/subscriber.rb +16 -9
  24. data/lib/kit/objects/subscriber_stats.rb +26 -0
  25. data/lib/kit/objects/tag.rb +5 -3
  26. data/lib/kit/objects/webhook_endpoint.rb +7 -2
  27. data/lib/kit/pagination.rb +47 -3
  28. data/lib/kit/resources/account.rb +9 -8
  29. data/lib/kit/resources/base.rb +46 -4
  30. data/lib/kit/resources/broadcasts.rb +39 -16
  31. data/lib/kit/resources/bulk.rb +18 -20
  32. data/lib/kit/resources/custom_fields.rb +2 -2
  33. data/lib/kit/resources/forms.rb +3 -3
  34. data/lib/kit/resources/posts.rb +1 -1
  35. data/lib/kit/resources/purchases.rb +10 -6
  36. data/lib/kit/resources/sequences.rb +49 -25
  37. data/lib/kit/resources/snippets.rb +13 -8
  38. data/lib/kit/resources/subscribers.rb +24 -11
  39. data/lib/kit/resources/tags.rb +6 -6
  40. data/lib/kit/resources/webhook_endpoints.rb +12 -4
  41. data/lib/kit/resources/webhooks.rb +1 -1
  42. data/lib/kit/version.rb +1 -1
  43. data/lib/kit/webhooks/delivery.rb +61 -0
  44. data/lib/kit/webhooks/events.rb +113 -0
  45. data/lib/kit/webhooks/signature.rb +100 -0
  46. data/lib/kit-rb.rb +10 -1
  47. data/sig/kit-rb.rbs +240 -37
  48. metadata +16 -2
  49. data/lib/kit/objects/raw.rb +0 -12
@@ -16,7 +16,7 @@ module Kit
16
16
 
17
17
  # GET /v4/webhook_endpoints/:id
18
18
  def get(id)
19
- one(:get, "/v4/webhook_endpoints/#{id}", "webhook_endpoint", Objects::WebhookEndpoint)
19
+ one(:get, "/v4/webhook_endpoints/#{path_id(id)}", "webhook_endpoint", Objects::WebhookEndpoint)
20
20
  end
21
21
 
22
22
  # POST /v4/webhook_endpoints
@@ -25,21 +25,29 @@ module Kit
25
25
  body: { url: url, events: events, name: name, description: description }.compact)
26
26
  end
27
27
 
28
+ # PATCH /v4/webhook_endpoints/:id — change name/url/description, pause or
29
+ # resume delivery with status: "active" | "disabled", or replace the
30
+ # subscribed events (the list given here replaces the whole set).
31
+ def update(id, name: nil, url: nil, description: nil, status: nil, events: nil)
32
+ body = { name: name, url: url, description: description, status: status, events: events }.compact
33
+ one(:patch, "/v4/webhook_endpoints/#{path_id(id)}", "webhook_endpoint", Objects::WebhookEndpoint, body: body)
34
+ end
35
+
28
36
  # DELETE /v4/webhook_endpoints/:id
29
37
  def delete(id)
30
- http_delete("/v4/webhook_endpoints/#{id}")
38
+ http_delete("/v4/webhook_endpoints/#{path_id(id)}")
31
39
  nil
32
40
  end
33
41
 
34
42
  # POST /v4/webhook_endpoints/:id/rotate_secret
35
43
  def rotate_secret(id, force: nil)
36
- one(:post, "/v4/webhook_endpoints/#{id}/rotate_secret", "webhook_endpoint", Objects::WebhookEndpoint,
44
+ one(:post, "/v4/webhook_endpoints/#{path_id(id)}/rotate_secret", "webhook_endpoint", Objects::WebhookEndpoint,
37
45
  body: { force: force }.compact)
38
46
  end
39
47
 
40
48
  # POST /v4/webhook_endpoints/:id/revoke_previous_secret
41
49
  def revoke_previous_secret(id)
42
- one(:post, "/v4/webhook_endpoints/#{id}/revoke_previous_secret", "webhook_endpoint", Objects::WebhookEndpoint)
50
+ one(:post, "/v4/webhook_endpoints/#{path_id(id)}/revoke_previous_secret", "webhook_endpoint", Objects::WebhookEndpoint)
43
51
  end
44
52
  end
45
53
  end
@@ -16,7 +16,7 @@ module Kit
16
16
 
17
17
  # DELETE /v4/webhooks/:id
18
18
  def delete(id)
19
- http_delete("/v4/webhooks/#{id}")
19
+ http_delete("/v4/webhooks/#{path_id(id)}")
20
20
  nil
21
21
  end
22
22
  end
data/lib/kit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kit
6
+ module Webhooks
7
+ # One event inside a delivery. `id` is the deduplication key (deliveries
8
+ # are retried whole, so the same event can arrive twice); `type` is the
9
+ # event name (e.g. "subscriber.created"); `created` an ISO 8601 UTC string;
10
+ # `data` the event-specific payload, keyed by resource ("subscriber",
11
+ # "tag", ...), left as a Hash because its shape varies per type.
12
+ Event = Data.define(:id, :type, :created, :data) do
13
+ def self.from(hash)
14
+ new(id: hash["id"], type: hash["type"], created: hash["created"], data: hash["data"] || {})
15
+ end
16
+ end
17
+
18
+ # The JSON envelope a webhook endpoint receives (developers.kit.com/
19
+ # webhooks/delivery-format, verified 2026-09-06): { delivery_id, events[] }
20
+ # with 1..100 events of one type, plus the headers X-Kit-Delivery (the same
21
+ # id), X-Kit-Signature, and User-Agent "Kit-Webhooks/2.0". Answer 2xx to
22
+ # acknowledge; anything else is retried (8 attempts over ~41 hours).
23
+ #
24
+ # delivery = Kit::Webhooks::Delivery.from_request(
25
+ # request.raw_post, request.headers["X-Kit-Signature"], secret: secret
26
+ # )
27
+ # delivery.events.each { |event| handle(event) unless seen?(event.id) }
28
+ # The header carrying the delivery id on each request.
29
+ DELIVERY_HEADER = "X-Kit-Delivery"
30
+
31
+ Delivery = Data.define(:delivery_id, :events) do
32
+ def self.from(hash)
33
+ new(delivery_id: hash["delivery_id"], events: Array(hash["events"]).map { |event| Event.from(event) })
34
+ end
35
+
36
+ # Parses a raw JSON body. Raises UnexpectedResponseError when it is not
37
+ # the documented envelope.
38
+ def self.parse(payload)
39
+ body = JSON.parse(payload)
40
+ raise UnexpectedResponseError.new("webhook delivery is not a JSON object", body: body) unless body.is_a?(Hash)
41
+
42
+ from(body)
43
+ rescue JSON::ParserError => e
44
+ raise UnexpectedResponseError.new("webhook delivery is not valid JSON: #{e.message}", body: payload)
45
+ end
46
+
47
+ # Verifies the signature, then parses. The one call a webhook receiver
48
+ # needs; a failed check raises SignatureError before any JSON is read.
49
+ def self.from_request(payload, signature_header, secret:, tolerance: Signature::DEFAULT_TOLERANCE,
50
+ now: Time.now.to_i)
51
+ Signature.verify!(payload, signature_header, secret: secret, tolerance: tolerance, now: now)
52
+ parse(payload)
53
+ end
54
+
55
+ # Every event in a delivery shares one type.
56
+ def type
57
+ events.first&.type
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Webhooks
5
+ # Event types a webhook endpoint (/v4/webhook_endpoints) can subscribe to,
6
+ # as listed at developers.kit.com/webhooks/event-types (verified
7
+ # 2026-09-06). Use these instead of hand-typed strings:
8
+ #
9
+ # client.webhook_endpoints.create(url: url, events: [Events::SUBSCRIBER_CREATED, Events::TAG_CREATED])
10
+ #
11
+ # DATA_KEYS names the keys each event's `data` carries. PLANNED events are
12
+ # documented but not yet delivered.
13
+ module Events
14
+ SUBSCRIBER_CREATED = "subscriber.created"
15
+ SUBSCRIBER_ACTIVATED = "subscriber.activated"
16
+ SUBSCRIBER_UNSUBSCRIBED = "subscriber.unsubscribed"
17
+ SUBSCRIBER_BOUNCED = "subscriber.bounced"
18
+ SUBSCRIBER_COMPLAINED = "subscriber.complained"
19
+ SUBSCRIBER_SUBSCRIBED_TO_FORM = "subscriber.subscribed_to_form"
20
+ SUBSCRIBER_ADDED_TO_SEQUENCE = "subscriber.added_to_sequence"
21
+ SUBSCRIBER_SEQUENCE_COMPLETED = "subscriber.sequence_completed"
22
+ SUBSCRIBER_TAG_ADDED = "subscriber.tag_added"
23
+ SUBSCRIBER_TAG_REMOVED = "subscriber.tag_removed"
24
+ SUBSCRIBER_CUSTOM_FIELD_VALUE_UPDATED = "subscriber.custom_field_value_updated"
25
+ SUBSCRIBER_PRODUCT_PURCHASED = "subscriber.product_purchased" # planned
26
+ SUBSCRIBER_LINK_CLICKED = "subscriber.link_clicked" # planned
27
+ SUBSCRIBER_EMAIL_OPENED = "subscriber.email_opened" # planned
28
+ TAG_CREATED = "tag.created"
29
+ TAG_DELETED = "tag.deleted"
30
+ CUSTOM_FIELD_CREATED = "custom_field.created"
31
+ CUSTOM_FIELD_DELETED = "custom_field.deleted"
32
+ SEQUENCE_CREATED = "sequence.created"
33
+ SEQUENCE_DELETED = "sequence.deleted"
34
+ SEQUENCE_PUBLISHED = "sequence.published"
35
+ SEQUENCE_DISABLED = "sequence.disabled"
36
+ BROADCAST_CREATED = "broadcast.created"
37
+ BROADCAST_SENT = "broadcast.sent"
38
+ BROADCAST_DELETED = "broadcast.deleted"
39
+ POST_PUBLISHED = "post.published"
40
+ LANDING_PAGE_CREATED = "landing_page.created" # planned
41
+ LANDING_PAGE_DELETED = "landing_page.deleted" # planned
42
+
43
+ DATA_KEYS = {
44
+ SUBSCRIBER_CREATED => %w[subscriber], SUBSCRIBER_ACTIVATED => %w[subscriber],
45
+ SUBSCRIBER_UNSUBSCRIBED => %w[subscriber], SUBSCRIBER_BOUNCED => %w[subscriber],
46
+ SUBSCRIBER_COMPLAINED => %w[subscriber], SUBSCRIBER_SUBSCRIBED_TO_FORM => %w[subscriber form],
47
+ SUBSCRIBER_ADDED_TO_SEQUENCE => %w[subscriber sequence],
48
+ SUBSCRIBER_SEQUENCE_COMPLETED => %w[subscriber sequence],
49
+ SUBSCRIBER_TAG_ADDED => %w[subscriber tag], SUBSCRIBER_TAG_REMOVED => %w[subscriber tag],
50
+ SUBSCRIBER_CUSTOM_FIELD_VALUE_UPDATED => %w[subscriber custom_field],
51
+ SUBSCRIBER_PRODUCT_PURCHASED => %w[subscriber], SUBSCRIBER_LINK_CLICKED => %w[subscriber],
52
+ SUBSCRIBER_EMAIL_OPENED => %w[subscriber],
53
+ TAG_CREATED => %w[tag], TAG_DELETED => %w[tag],
54
+ CUSTOM_FIELD_CREATED => %w[custom_field], CUSTOM_FIELD_DELETED => %w[custom_field],
55
+ SEQUENCE_CREATED => %w[sequence], SEQUENCE_DELETED => %w[sequence],
56
+ SEQUENCE_PUBLISHED => %w[sequence], SEQUENCE_DISABLED => %w[sequence],
57
+ BROADCAST_CREATED => %w[broadcast], BROADCAST_SENT => %w[broadcast], BROADCAST_DELETED => %w[broadcast],
58
+ POST_PUBLISHED => %w[post],
59
+ LANDING_PAGE_CREATED => [], LANDING_PAGE_DELETED => []
60
+ }.freeze
61
+
62
+ PLANNED = [
63
+ SUBSCRIBER_PRODUCT_PURCHASED, SUBSCRIBER_LINK_CLICKED, SUBSCRIBER_EMAIL_OPENED,
64
+ LANDING_PAGE_CREATED, LANDING_PAGE_DELETED
65
+ ].freeze
66
+
67
+ ALL = DATA_KEYS.keys.freeze
68
+ AVAILABLE = (ALL - PLANNED).freeze
69
+ end
70
+
71
+ # Event names for the previous-generation /v4/webhooks (one event per
72
+ # webhook, unsigned). Kept for existing integrations; new ones should use
73
+ # webhook endpoints. REQUIRED_PARAM names the extra key the `event` hash
74
+ # must carry for the events that are scoped to a resource:
75
+ #
76
+ # client.webhooks.create(target_url: url,
77
+ # event: { name: LegacyEvents::TAG_ADD, tag_id: 12 })
78
+ module LegacyEvents
79
+ SUBSCRIBER_ACTIVATE = "subscriber.subscriber_activate"
80
+ SUBSCRIBER_UNSUBSCRIBE = "subscriber.subscriber_unsubscribe"
81
+ SUBSCRIBER_BOUNCE = "subscriber.subscriber_bounce"
82
+ SUBSCRIBER_COMPLAIN = "subscriber.subscriber_complain"
83
+ FORM_SUBSCRIBE = "subscriber.form_subscribe"
84
+ COURSE_SUBSCRIBE = "subscriber.course_subscribe"
85
+ COURSE_COMPLETE = "subscriber.course_complete"
86
+ LINK_CLICK = "subscriber.link_click"
87
+ PRODUCT_PURCHASE = "subscriber.product_purchase"
88
+ TAG_ADD = "subscriber.tag_add"
89
+ TAG_REMOVE = "subscriber.tag_remove"
90
+ PURCHASE_CREATE = "purchase.purchase_create"
91
+ FIELD_CREATED = "custom_field.field_created"
92
+ FIELD_DELETED = "custom_field.field_deleted"
93
+ FIELD_VALUE_UPDATED = "custom_field.field_value_updated"
94
+
95
+ REQUIRED_PARAM = {
96
+ FORM_SUBSCRIBE => :form_id,
97
+ COURSE_SUBSCRIBE => :sequence_id,
98
+ COURSE_COMPLETE => :sequence_id,
99
+ LINK_CLICK => :initiator_value,
100
+ PRODUCT_PURCHASE => :product_id,
101
+ TAG_ADD => :tag_id,
102
+ TAG_REMOVE => :tag_id,
103
+ FIELD_VALUE_UPDATED => :custom_field_id
104
+ }.freeze
105
+
106
+ ALL = [
107
+ SUBSCRIBER_ACTIVATE, SUBSCRIBER_UNSUBSCRIBE, SUBSCRIBER_BOUNCE, SUBSCRIBER_COMPLAIN,
108
+ FORM_SUBSCRIBE, COURSE_SUBSCRIBE, COURSE_COMPLETE, LINK_CLICK, PRODUCT_PURCHASE,
109
+ TAG_ADD, TAG_REMOVE, PURCHASE_CREATE, FIELD_CREATED, FIELD_DELETED, FIELD_VALUE_UPDATED
110
+ ].freeze
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Kit
6
+ module Webhooks
7
+ # Raised when a delivery's X-Kit-Signature is missing, malformed, stale, or
8
+ # does not match the body under any supplied secret.
9
+ class SignatureError < Error; end
10
+
11
+ # Verifies the `X-Kit-Signature` header Kit sends with every webhook-endpoint
12
+ # delivery (verified against developers.kit.com/webhooks/verifying-signatures,
13
+ # 2026-09-06):
14
+ #
15
+ # X-Kit-Signature: t=1753797130,v1=<hex>[,v1=<hex>]
16
+ #
17
+ # Each v1 is hex(HMAC-SHA256(secret, "#{t}.#{raw_body}")). During a secret
18
+ # rotation the header carries two v1 entries (new and previous secret), so
19
+ # a delivery is valid when any v1 matches any of the secrets you hold.
20
+ #
21
+ # Kit::Webhooks::Signature.verify!(request.raw_post, request.headers["X-Kit-Signature"],
22
+ # secret: ENV["KIT_WEBHOOK_SECRET"])
23
+ module Signature
24
+ HEADER = "X-Kit-Signature"
25
+ SCHEME = "v1"
26
+ DEFAULT_TOLERANCE = 300 # seconds; Kit's recommended replay window
27
+
28
+ Parsed = Data.define(:timestamp, :signatures)
29
+
30
+ # hex(HMAC-SHA256(secret, "t.payload")) — what Kit puts in v1.
31
+ def self.compute(secret, timestamp, payload)
32
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
33
+ end
34
+
35
+ # Splits the header into its timestamp and v1 signatures. Pairs are kept
36
+ # as a list, not a Hash: a rotation sends two v1 entries.
37
+ def self.parse(header)
38
+ raise SignatureError, "missing #{HEADER} header" if header.nil? || header.strip.empty?
39
+
40
+ pairs = header.split(",").map { |pair| pair.strip.split("=", 2) }
41
+ signatures = pairs.filter_map { |key, value| value if key == SCHEME }
42
+ raise SignatureError, "malformed #{HEADER} header: #{header.inspect}" if signatures.empty?
43
+
44
+ Parsed.new(timestamp: parse_timestamp(pairs, header), signatures: signatures)
45
+ end
46
+
47
+ def self.parse_timestamp(pairs, header)
48
+ value = pairs.find { |key, _| key == "t" }&.last
49
+ raise SignatureError, "malformed #{HEADER} header: #{header.inspect}" if value.nil?
50
+ raise SignatureError, "malformed timestamp in #{HEADER}: #{value.inspect}" unless value.match?(/\A\d+\z/)
51
+
52
+ value.to_i
53
+ end
54
+ private_class_method :parse_timestamp
55
+
56
+ # True when the payload was signed by one of `secret` (a String, or an
57
+ # Array of Strings while you hold both sides of a rotation) within
58
+ # `tolerance` seconds of `now`. Constant-time comparison.
59
+ def self.verify?(payload, header, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now.to_i)
60
+ verify!(payload, header, secret: secret, tolerance: tolerance, now: now)
61
+ true
62
+ rescue SignatureError
63
+ false
64
+ end
65
+
66
+ # As #verify?, but raises SignatureError describing why the delivery was
67
+ # rejected. `payload` must be the raw request body, byte for byte.
68
+ def self.verify!(payload, header, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now.to_i)
69
+ secrets = secrets_from(secret)
70
+ parsed = parse(header)
71
+ check_freshness(parsed.timestamp, tolerance, now)
72
+ return true if secrets.any? { |value| matches?(value, parsed, payload) }
73
+
74
+ raise SignatureError, "no #{SCHEME} signature matched the payload"
75
+ end
76
+
77
+ def self.secrets_from(secret)
78
+ secrets = Array(secret).reject { |value| value.nil? || value.empty? }
79
+ raise ArgumentError, "at least one webhook secret is required" if secrets.empty?
80
+
81
+ secrets
82
+ end
83
+ private_class_method :secrets_from
84
+
85
+ def self.check_freshness(timestamp, tolerance, now)
86
+ return unless tolerance
87
+
88
+ age = (now - timestamp).abs
89
+ raise SignatureError, "timestamp outside tolerance (#{age}s > #{tolerance}s)" if age > tolerance
90
+ end
91
+ private_class_method :check_freshness
92
+
93
+ def self.matches?(secret, parsed, payload)
94
+ expected = compute(secret, parsed.timestamp, payload)
95
+ parsed.signatures.any? { |given| OpenSSL.secure_compare(expected, given) }
96
+ end
97
+ private_class_method :matches?
98
+ end
99
+ end
100
+ end
data/lib/kit-rb.rb CHANGED
@@ -15,6 +15,7 @@ end
15
15
  require "kit/version"
16
16
  require "kit/errors"
17
17
  require "kit/configuration"
18
+ require "kit/auth/credential"
18
19
  require "kit/auth/api_key"
19
20
  require "kit/auth/oauth"
20
21
  require "kit/connection"
@@ -22,7 +23,6 @@ require "kit/pagination"
22
23
  require "kit/oauth/pkce"
23
24
  require "kit/oauth/token"
24
25
  require "kit/oauth/client"
25
- require "kit/objects/raw"
26
26
  require "kit/objects/account"
27
27
  require "kit/objects/creator_profile"
28
28
  require "kit/objects/subscriber"
@@ -32,6 +32,11 @@ require "kit/objects/form"
32
32
  require "kit/objects/sequence"
33
33
  require "kit/objects/sequence_email"
34
34
  require "kit/objects/broadcast"
35
+ require "kit/objects/broadcast_stats"
36
+ require "kit/objects/broadcast_click"
37
+ require "kit/objects/subscriber_stats"
38
+ require "kit/objects/email_stats"
39
+ require "kit/objects/growth_stats"
35
40
  require "kit/objects/webhook"
36
41
  require "kit/objects/webhook_endpoint"
37
42
  require "kit/objects/email_template"
@@ -39,6 +44,10 @@ require "kit/objects/segment"
39
44
  require "kit/objects/post"
40
45
  require "kit/objects/snippet"
41
46
  require "kit/objects/purchase"
47
+ require "kit/objects/bulk_result"
48
+ require "kit/webhooks/signature"
49
+ require "kit/webhooks/delivery"
50
+ require "kit/webhooks/events"
42
51
  require "kit/resources/base"
43
52
  require "kit/resources/account"
44
53
  require "kit/resources/subscribers"