kit-rb 0.2.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 (43) 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 +70 -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/bulk_result.rb +39 -0
  16. data/lib/kit/objects/custom_field.rb +4 -2
  17. data/lib/kit/objects/post.rb +4 -2
  18. data/lib/kit/objects/sequence_email.rb +5 -2
  19. data/lib/kit/objects/subscriber.rb +16 -9
  20. data/lib/kit/objects/tag.rb +5 -3
  21. data/lib/kit/objects/webhook_endpoint.rb +7 -2
  22. data/lib/kit/pagination.rb +47 -3
  23. data/lib/kit/resources/account.rb +4 -4
  24. data/lib/kit/resources/base.rb +46 -4
  25. data/lib/kit/resources/broadcasts.rb +32 -13
  26. data/lib/kit/resources/bulk.rb +18 -20
  27. data/lib/kit/resources/custom_fields.rb +2 -2
  28. data/lib/kit/resources/forms.rb +3 -3
  29. data/lib/kit/resources/posts.rb +1 -1
  30. data/lib/kit/resources/purchases.rb +10 -6
  31. data/lib/kit/resources/sequences.rb +49 -25
  32. data/lib/kit/resources/snippets.rb +13 -8
  33. data/lib/kit/resources/subscribers.rb +23 -9
  34. data/lib/kit/resources/tags.rb +6 -6
  35. data/lib/kit/resources/webhook_endpoints.rb +12 -4
  36. data/lib/kit/resources/webhooks.rb +1 -1
  37. data/lib/kit/version.rb +1 -1
  38. data/lib/kit/webhooks/delivery.rb +61 -0
  39. data/lib/kit/webhooks/events.rb +113 -0
  40. data/lib/kit/webhooks/signature.rb +100 -0
  41. data/lib/kit-rb.rb +5 -0
  42. data/sig/kit-rb.rbs +170 -29
  43. metadata +11 -1
@@ -3,60 +3,58 @@
3
3
  module Kit
4
4
  module Resources
5
5
  # The /v4/bulk endpoints — batch operations that all require OAuth. Each call
6
- # takes an array of items and an optional callback_url, and returns the raw
7
- # result hash: the created/affected items under their resource key plus a
8
- # "failures" array. Large batches are processed asynchronously and reported
9
- # to callback_url; the response still carries whatever completed inline.
6
+ # takes an array of items and an optional callback_url and returns a
7
+ # BulkResult: the affected records under `items`, the rejected inputs under
8
+ # `failures`, and `async?` when Kit queued the batch (202) and will report
9
+ # the outcome to callback_url instead.
10
10
  class Bulk < Base
11
11
  # POST /v4/bulk/subscribers
12
12
  def create_subscribers(subscribers, callback_url: nil)
13
- post("/v4/bulk/subscribers", subscribers: subscribers, callback_url: callback_url)
13
+ bulk(:post, "/v4/bulk/subscribers", "subscribers", subscribers: subscribers, callback_url: callback_url)
14
14
  end
15
15
 
16
16
  # POST /v4/bulk/custom_fields
17
17
  def create_custom_fields(custom_fields, callback_url: nil)
18
- post("/v4/bulk/custom_fields", custom_fields: custom_fields, callback_url: callback_url)
18
+ bulk(:post, "/v4/bulk/custom_fields", "custom_fields",
19
+ custom_fields: custom_fields, callback_url: callback_url)
19
20
  end
20
21
 
21
22
  # POST /v4/bulk/custom_fields/subscribers
22
23
  def update_custom_field_values(custom_field_values, callback_url: nil)
23
- post("/v4/bulk/custom_fields/subscribers",
24
+ bulk(:post, "/v4/bulk/custom_fields/subscribers", "custom_field_values",
24
25
  custom_field_values: custom_field_values, callback_url: callback_url)
25
26
  end
26
27
 
27
28
  # POST /v4/bulk/forms/subscribers
28
29
  def add_subscribers_to_forms(additions, callback_url: nil)
29
- post("/v4/bulk/forms/subscribers", additions: additions, callback_url: callback_url)
30
+ bulk(:post, "/v4/bulk/forms/subscribers", "subscribers", additions: additions, callback_url: callback_url)
30
31
  end
31
32
 
32
33
  # POST /v4/bulk/tags
33
34
  def create_tags(tags, callback_url: nil)
34
- post("/v4/bulk/tags", tags: tags, callback_url: callback_url)
35
+ bulk(:post, "/v4/bulk/tags", "tags", tags: tags, callback_url: callback_url)
35
36
  end
36
37
 
37
- # DELETE /v4/bulk/tags
38
+ # DELETE /v4/bulk/tags — returns failures only.
38
39
  def delete_tags(tags, callback_url: nil)
39
- delete("/v4/bulk/tags", tags: tags, callback_url: callback_url)
40
+ bulk(:delete, "/v4/bulk/tags", nil, tags: tags, callback_url: callback_url)
40
41
  end
41
42
 
42
43
  # POST /v4/bulk/tags/subscribers
43
44
  def tag_subscribers(taggings, callback_url: nil)
44
- post("/v4/bulk/tags/subscribers", taggings: taggings, callback_url: callback_url)
45
+ bulk(:post, "/v4/bulk/tags/subscribers", "subscribers", taggings: taggings, callback_url: callback_url)
45
46
  end
46
47
 
47
- # DELETE /v4/bulk/tags/subscribers
48
+ # DELETE /v4/bulk/tags/subscribers — returns failures only.
48
49
  def remove_tag_subscribers(taggings, callback_url: nil)
49
- delete("/v4/bulk/tags/subscribers", taggings: taggings, callback_url: callback_url)
50
+ bulk(:delete, "/v4/bulk/tags/subscribers", nil, taggings: taggings, callback_url: callback_url)
50
51
  end
51
52
 
52
53
  private
53
54
 
54
- def post(path, **body)
55
- http_post(path, body: body.compact)
56
- end
57
-
58
- def delete(path, **body)
59
- http_delete(path, body: body.compact)
55
+ def bulk(verb, path, key, **body)
56
+ status, response = @connection.request_with_status(verb, path, body: body.compact)
57
+ Objects::BulkResult.from(status, response, key)
60
58
  end
61
59
  end
62
60
  end
@@ -16,12 +16,12 @@ module Kit
16
16
 
17
17
  # PUT /v4/custom_fields/:id
18
18
  def update(id, label:)
19
- one(:put, "/v4/custom_fields/#{id}", "custom_field", Objects::CustomField, body: { label: label })
19
+ one(:put, "/v4/custom_fields/#{path_id(id)}", "custom_field", Objects::CustomField, body: { label: label })
20
20
  end
21
21
 
22
22
  # DELETE /v4/custom_fields/:id
23
23
  def delete(id)
24
- http_delete("/v4/custom_fields/#{id}")
24
+ http_delete("/v4/custom_fields/#{path_id(id)}")
25
25
  nil
26
26
  end
27
27
  end
@@ -12,18 +12,18 @@ module Kit
12
12
 
13
13
  # GET /v4/forms/:form_id/subscribers
14
14
  def subscribers(form_id, **params)
15
- collection("/v4/forms/#{form_id}/subscribers", "subscribers", Objects::Subscriber, params)
15
+ collection("/v4/forms/#{path_id(form_id)}/subscribers", "subscribers", Objects::Subscriber, params)
16
16
  end
17
17
 
18
18
  # POST /v4/forms/:form_id/subscribers/:subscriber_id
19
19
  def add_subscriber(form_id, subscriber_id, referrer: nil)
20
- one(:post, "/v4/forms/#{form_id}/subscribers/#{subscriber_id}", "subscriber",
20
+ one(:post, "/v4/forms/#{path_id(form_id)}/subscribers/#{path_id(subscriber_id)}", "subscriber",
21
21
  Objects::Subscriber, body: { referrer: referrer }.compact)
22
22
  end
23
23
 
24
24
  # POST /v4/forms/:form_id/subscribers
25
25
  def add_subscriber_by_email(form_id, email_address:, referrer: nil)
26
- one(:post, "/v4/forms/#{form_id}/subscribers", "subscriber", Objects::Subscriber,
26
+ one(:post, "/v4/forms/#{path_id(form_id)}/subscribers", "subscriber", Objects::Subscriber,
27
27
  body: { email_address: email_address, referrer: referrer }.compact)
28
28
  end
29
29
  end
@@ -11,7 +11,7 @@ module Kit
11
11
 
12
12
  # GET /v4/posts/:id
13
13
  def get(id)
14
- one(:get, "/v4/posts/#{id}", "post", Objects::Post)
14
+ one(:get, "/v4/posts/#{path_id(id)}", "post", Objects::Post)
15
15
  end
16
16
  end
17
17
  end
@@ -12,14 +12,18 @@ module Kit
12
12
 
13
13
  # GET /v4/purchases/:id
14
14
  def get(id)
15
- one(:get, "/v4/purchases/#{id}", "purchase", Objects::Purchase)
15
+ one(:get, "/v4/purchases/#{path_id(id)}", "purchase", Objects::Purchase)
16
16
  end
17
17
 
18
- # POST /v4/purchases — the request wraps the fields under a "purchase" key.
19
- # Pass email_address/transaction_id/status/currency/transaction_time,
20
- # the monetary totals, and a products array.
21
- def create(**attributes)
22
- one(:post, "/v4/purchases", "purchase", Objects::Purchase, body: { purchase: attributes })
18
+ # POST /v4/purchases — the request wraps the fields under a "purchase"
19
+ # key. The spec marks every field required; `products` is an array of
20
+ # { name, pid, lid, quantity, unit_price, sku }.
21
+ def create(email_address:, transaction_id:, status:, currency:, transaction_time: OMIT, subtotal: OMIT,
22
+ tax: OMIT, shipping: OMIT, discount: OMIT, total: OMIT, products: OMIT)
23
+ purchase = given(email_address: email_address, transaction_id: transaction_id, status: status,
24
+ currency: currency, transaction_time: transaction_time, subtotal: subtotal, tax: tax,
25
+ shipping: shipping, discount: discount, total: total, products: products)
26
+ one(:post, "/v4/purchases", "purchase", Objects::Purchase, body: { purchase: purchase })
23
27
  end
24
28
  end
25
29
  end
@@ -10,67 +10,91 @@ module Kit
10
10
  collection("/v4/sequences", "sequences", Objects::Sequence, params)
11
11
  end
12
12
 
13
- # GET /v4/sequences/:id
14
- def get(id)
15
- one(:get, "/v4/sequences/#{id}", "sequence", Objects::Sequence)
13
+ # GET /v4/sequences/:id — pass include: "stats" to embed the sequence's
14
+ # performance stats.
15
+ def get(id, include: nil)
16
+ one(:get, "/v4/sequences/#{path_id(id)}", "sequence", Objects::Sequence, params: { include: include }.compact)
16
17
  end
17
18
 
18
19
  # GET /v4/sequences/:sequence_id/subscribers
19
20
  def subscribers(sequence_id, **params)
20
- collection("/v4/sequences/#{sequence_id}/subscribers", "subscribers", Objects::Subscriber, params)
21
+ collection("/v4/sequences/#{path_id(sequence_id)}/subscribers", "subscribers", Objects::Subscriber, params)
21
22
  end
22
23
 
23
- # POST /v4/sequences
24
- def create(name:, **attributes)
25
- one(:post, "/v4/sequences", "sequence", Objects::Sequence, body: { name: name }.merge(attributes))
24
+ # POST /v4/sequences. `send_days` is an array of weekday names,
25
+ # `send_hour` 0–23, `time_zone` an IANA name; `exclude_subscriber_sources`
26
+ # is [{ type: "tag"|"sequence"|"form"|"segment", ids: [...] }].
27
+ def create(name:, email_address: OMIT, email_template_id: OMIT, send_days: OMIT, send_hour: OMIT,
28
+ time_zone: OMIT, active: OMIT, repeat: OMIT, hold: OMIT, exclude_subscriber_sources: OMIT)
29
+ body = given(name: name, email_address: email_address, email_template_id: email_template_id,
30
+ send_days: send_days, send_hour: send_hour, time_zone: time_zone, active: active,
31
+ repeat: repeat, hold: hold, exclude_subscriber_sources: exclude_subscriber_sources)
32
+ one(:post, "/v4/sequences", "sequence", Objects::Sequence, body: body)
26
33
  end
27
34
 
28
- # PUT /v4/sequences/:id
29
- def update(id, **attributes)
30
- one(:put, "/v4/sequences/#{id}", "sequence", Objects::Sequence, body: attributes)
35
+ # PUT /v4/sequences/:id — same fields as #create; only those passed change.
36
+ def update(id, name: OMIT, email_address: OMIT, email_template_id: OMIT, send_days: OMIT, send_hour: OMIT,
37
+ time_zone: OMIT, active: OMIT, repeat: OMIT, hold: OMIT, exclude_subscriber_sources: OMIT)
38
+ body = given(name: name, email_address: email_address, email_template_id: email_template_id,
39
+ send_days: send_days, send_hour: send_hour, time_zone: time_zone, active: active,
40
+ repeat: repeat, hold: hold, exclude_subscriber_sources: exclude_subscriber_sources)
41
+ one(:put, "/v4/sequences/#{path_id(id)}", "sequence", Objects::Sequence, body: body)
31
42
  end
32
43
 
33
44
  # DELETE /v4/sequences/:id
34
45
  def delete(id)
35
- http_delete("/v4/sequences/#{id}")
46
+ http_delete("/v4/sequences/#{path_id(id)}")
36
47
  nil
37
48
  end
38
49
 
39
50
  # POST /v4/sequences/:sequence_id/subscribers/:subscriber_id
40
51
  def add_subscriber(sequence_id, subscriber_id)
41
- one(:post, "/v4/sequences/#{sequence_id}/subscribers/#{subscriber_id}", "subscriber", Objects::Subscriber)
52
+ one(:post, "/v4/sequences/#{path_id(sequence_id)}/subscribers/#{path_id(subscriber_id)}", "subscriber", Objects::Subscriber)
42
53
  end
43
54
 
44
55
  # POST /v4/sequences/:sequence_id/subscribers
45
56
  def add_subscriber_by_email(sequence_id, email_address:)
46
- one(:post, "/v4/sequences/#{sequence_id}/subscribers", "subscriber", Objects::Subscriber,
57
+ one(:post, "/v4/sequences/#{path_id(sequence_id)}/subscribers", "subscriber", Objects::Subscriber,
47
58
  body: { email_address: email_address })
48
59
  end
49
60
 
50
61
  # GET /v4/sequences/:sequence_id/emails
51
62
  def emails(sequence_id, **params)
52
- collection("/v4/sequences/#{sequence_id}/emails", "emails", Objects::SequenceEmail, params)
63
+ collection("/v4/sequences/#{path_id(sequence_id)}/emails", "emails", Objects::SequenceEmail, params)
53
64
  end
54
65
 
55
- # GET /v4/sequences/:sequence_id/emails/:id
56
- def email(sequence_id, id)
57
- one(:get, "/v4/sequences/#{sequence_id}/emails/#{id}", "email", Objects::SequenceEmail)
66
+ # GET /v4/sequences/:sequence_id/emails/:id — include: "stats" embeds the
67
+ # email's performance stats.
68
+ def email(sequence_id, id, include: nil)
69
+ one(:get, "/v4/sequences/#{path_id(sequence_id)}/emails/#{path_id(id)}", "email", Objects::SequenceEmail,
70
+ params: { include: include }.compact)
58
71
  end
59
72
 
60
- # POST /v4/sequences/:sequence_id/emails subject/delay_value/delay_unit
61
- # are required; content/position/send_days and the rest are optional.
62
- def create_email(sequence_id, **attributes)
63
- one(:post, "/v4/sequences/#{sequence_id}/emails", "email", Objects::SequenceEmail, body: attributes)
73
+ # POST /v4/sequences/:sequence_id/emails. `delay_unit` is "days" or
74
+ # "hours"; `send_days` nil resets to all seven days; `position` is
75
+ # zero-based and defaults to last.
76
+ def create_email(sequence_id, subject:, delay_value:, delay_unit:, preview_text: OMIT, content: OMIT,
77
+ email_template_id: OMIT, published: OMIT, send_days: OMIT, position: OMIT)
78
+ body = given(subject: subject, delay_value: delay_value, delay_unit: delay_unit, preview_text: preview_text,
79
+ content: content, email_template_id: email_template_id, published: published,
80
+ send_days: send_days, position: position)
81
+ one(:post, "/v4/sequences/#{path_id(sequence_id)}/emails", "email", Objects::SequenceEmail, body: body)
64
82
  end
65
83
 
66
- # PUT /v4/sequences/:sequence_id/emails/:id
67
- def update_email(sequence_id, id, **attributes)
68
- one(:put, "/v4/sequences/#{sequence_id}/emails/#{id}", "email", Objects::SequenceEmail, body: attributes)
84
+ # PUT /v4/sequences/:sequence_id/emails/:id — only the fields passed
85
+ # change; pass nil for email_template_id or send_days to clear them.
86
+ def update_email(sequence_id, id, subject: OMIT, delay_value: OMIT, delay_unit: OMIT, preview_text: OMIT,
87
+ content: OMIT, email_template_id: OMIT, published: OMIT, send_days: OMIT, position: OMIT)
88
+ body = given(subject: subject, delay_value: delay_value, delay_unit: delay_unit, preview_text: preview_text,
89
+ content: content, email_template_id: email_template_id, published: published,
90
+ send_days: send_days, position: position)
91
+ one(:put, "/v4/sequences/#{path_id(sequence_id)}/emails/#{path_id(id)}", "email", Objects::SequenceEmail,
92
+ body: body)
69
93
  end
70
94
 
71
95
  # DELETE /v4/sequences/:sequence_id/emails/:id
72
96
  def delete_email(sequence_id, id)
73
- http_delete("/v4/sequences/#{sequence_id}/emails/#{id}")
97
+ http_delete("/v4/sequences/#{path_id(sequence_id)}/emails/#{path_id(id)}")
74
98
  nil
75
99
  end
76
100
  end
@@ -11,18 +11,23 @@ module Kit
11
11
 
12
12
  # GET /v4/snippets/:id
13
13
  def get(id)
14
- one(:get, "/v4/snippets/#{id}", "snippet", Objects::Snippet)
14
+ one(:get, "/v4/snippets/#{path_id(id)}", "snippet", Objects::Snippet)
15
15
  end
16
16
 
17
- # POST /v4/snippets pass name/snippet_type and content (inline) or
18
- # document (document); the API validates the combination.
19
- def create(**attributes)
20
- one(:post, "/v4/snippets", "snippet", Objects::Snippet, body: attributes)
17
+ # POST /v4/snippets. An "inline" snippet carries Liquid text in
18
+ # `content`; a "block" snippet carries HTML in
19
+ # `document_attributes: { value_html: ... }`.
20
+ def create(name:, snippet_type:, content: OMIT, document_attributes: OMIT)
21
+ body = given(name: name, snippet_type: snippet_type, content: content,
22
+ document_attributes: document_attributes)
23
+ one(:post, "/v4/snippets", "snippet", Objects::Snippet, body: body)
21
24
  end
22
25
 
23
- # PUT /v4/snippets/:id
24
- def update(id, **attributes)
25
- one(:put, "/v4/snippets/#{id}", "snippet", Objects::Snippet, body: attributes)
26
+ # PUT /v4/snippets/:id — rename, archive/restore, or replace the content
27
+ # (snippet_type cannot change).
28
+ def update(id, name: OMIT, archived: OMIT, content: OMIT, document_attributes: OMIT)
29
+ body = given(name: name, archived: archived, content: content, document_attributes: document_attributes)
30
+ one(:put, "/v4/snippets/#{path_id(id)}", "snippet", Objects::Snippet, body: body)
26
31
  end
27
32
  end
28
33
  end
@@ -13,7 +13,7 @@ module Kit
13
13
 
14
14
  # GET /v4/subscribers/:id
15
15
  def get(id)
16
- one(:get, "/v4/subscribers/#{id}", "subscriber", Objects::Subscriber)
16
+ one(:get, "/v4/subscribers/#{path_id(id)}", "subscriber", Objects::Subscriber)
17
17
  end
18
18
 
19
19
  # POST /v4/subscribers — email_address required; first_name, state, fields optional.
@@ -26,12 +26,17 @@ module Kit
26
26
  # PUT /v4/subscribers/:id
27
27
  def update(id, first_name: nil, email_address: nil, fields: nil)
28
28
  body = { first_name: first_name, email_address: email_address, fields: fields }.compact
29
- one(:put, "/v4/subscribers/#{id}", "subscriber", Objects::Subscriber, body: body)
29
+ one(:put, "/v4/subscribers/#{path_id(id)}", "subscriber", Objects::Subscriber, body: body)
30
30
  end
31
31
 
32
- # POST /v4/subscribers/:id/unsubscribe
32
+ # POST /v4/subscribers/:id/unsubscribe — the API answers 204 with no body,
33
+ # so this returns nil. Should Kit ever echo the subscriber back, it is
34
+ # returned as a Subscriber instead of being discarded.
33
35
  def unsubscribe(id)
34
- one(:post, "/v4/subscribers/#{id}/unsubscribe", "subscriber", Objects::Subscriber)
36
+ body = http_post("/v4/subscribers/#{path_id(id)}/unsubscribe")
37
+ return nil unless body.is_a?(Hash) && body.key?("subscriber")
38
+
39
+ Objects::Subscriber.from(body.fetch("subscriber"))
35
40
  end
36
41
 
37
42
  # POST /v4/subscribers/filter — the same filters as #list, sent in the
@@ -42,24 +47,33 @@ module Kit
42
47
 
43
48
  # GET /v4/subscribers/:id/tags — the tags applied to a subscriber.
44
49
  def tags(id, **params)
45
- collection("/v4/subscribers/#{id}/tags", "tags", Objects::Tag, params)
50
+ collection("/v4/subscribers/#{path_id(id)}/tags", "tags", Objects::Tag, params)
46
51
  end
47
52
 
48
53
  # GET /v4/subscribers/:id/stats — the subscriber's engagement stats.
49
- def stats(id)
50
- one(:get, "/v4/subscribers/#{id}/stats", "subscriber", Objects::SubscriberStats)
54
+ # Bound the window with email_sent_after / email_sent_before (yyyy-mm-dd).
55
+ def stats(id, email_sent_after: nil, email_sent_before: nil)
56
+ params = { email_sent_after: email_sent_after, email_sent_before: email_sent_before }.compact
57
+ one(:get, "/v4/subscribers/#{path_id(id)}/stats", "subscriber", Objects::SubscriberStats, params: params)
51
58
  end
52
59
 
53
60
  # POST /v4/subscribers/:id/location — set the subscriber's location
54
61
  # (a hash of city/state_province/country_code/latitude/longitude/timezone).
55
62
  def set_location(id, location:)
56
- one(:post, "/v4/subscribers/#{id}/location", "subscriber", Objects::Subscriber,
63
+ one(:post, "/v4/subscribers/#{path_id(id)}/location", "subscriber", Objects::Subscriber,
64
+ body: { location: location })
65
+ end
66
+
67
+ # PATCH /v4/subscribers/:id/location — replace a pinned location. Kit
68
+ # requires the full location (all six keys) on update, not a partial.
69
+ def update_location(id, location:)
70
+ one(:patch, "/v4/subscribers/#{path_id(id)}/location", "subscriber", Objects::Subscriber,
57
71
  body: { location: location })
58
72
  end
59
73
 
60
74
  # DELETE /v4/subscribers/:id/location
61
75
  def remove_location(id)
62
- http_delete("/v4/subscribers/#{id}/location")
76
+ http_delete("/v4/subscribers/#{path_id(id)}/location")
63
77
  nil
64
78
  end
65
79
  end
@@ -16,36 +16,36 @@ module Kit
16
16
 
17
17
  # PUT /v4/tags/:id
18
18
  def update(id, name:)
19
- one(:put, "/v4/tags/#{id}", "tag", Objects::Tag, body: { name: name })
19
+ one(:put, "/v4/tags/#{path_id(id)}", "tag", Objects::Tag, body: { name: name })
20
20
  end
21
21
 
22
22
  # POST /v4/tags/:tag_id/subscribers/:id
23
23
  def tag_subscriber(tag_id, subscriber_id)
24
- one(:post, "/v4/tags/#{tag_id}/subscribers/#{subscriber_id}", "subscriber", Objects::Subscriber)
24
+ one(:post, "/v4/tags/#{path_id(tag_id)}/subscribers/#{path_id(subscriber_id)}", "subscriber", Objects::Subscriber)
25
25
  end
26
26
 
27
27
  # DELETE /v4/tags/:tag_id/subscribers/:id
28
28
  def remove_subscriber(tag_id, subscriber_id)
29
- http_delete("/v4/tags/#{tag_id}/subscribers/#{subscriber_id}")
29
+ http_delete("/v4/tags/#{path_id(tag_id)}/subscribers/#{path_id(subscriber_id)}")
30
30
  nil
31
31
  end
32
32
 
33
33
  # POST /v4/tags/:tag_id/subscribers — tag a subscriber by email address.
34
34
  def tag_subscriber_by_email(tag_id, email_address:)
35
- one(:post, "/v4/tags/#{tag_id}/subscribers", "subscriber", Objects::Subscriber,
35
+ one(:post, "/v4/tags/#{path_id(tag_id)}/subscribers", "subscriber", Objects::Subscriber,
36
36
  body: { email_address: email_address })
37
37
  end
38
38
 
39
39
  # DELETE /v4/tags/:tag_id/subscribers — remove a tag from a subscriber by
40
40
  # email address (passed as a query parameter).
41
41
  def remove_subscriber_by_email(tag_id, email_address:)
42
- http_delete("/v4/tags/#{tag_id}/subscribers", params: { email_address: email_address })
42
+ http_delete("/v4/tags/#{path_id(tag_id)}/subscribers", params: { email_address: email_address })
43
43
  nil
44
44
  end
45
45
 
46
46
  # GET /v4/tags/:tag_id/subscribers
47
47
  def subscribers(tag_id, **params)
48
- collection("/v4/tags/#{tag_id}/subscribers", "subscribers", Objects::Subscriber, params)
48
+ collection("/v4/tags/#{path_id(tag_id)}/subscribers", "subscribers", Objects::Subscriber, params)
49
49
  end
50
50
  end
51
51
  end
@@ -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.2.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