kit_api 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4996d89da108c8784c235ec730e4d2b4137701d41c1942dfa3e6020d0f7c692e
4
+ data.tar.gz: 95b2e1edd7d2f15effd72df4d75804202a6685b82e08fcd35f485b51219d4f63
5
+ SHA512:
6
+ metadata.gz: '070426945ce384b443242bc1f72fe3546449fc5bfb05cde203b9bffe62177bdcf82992c5537a79e4881ad70cf0319429bae37f702921067bd35fc3eabb232364'
7
+ data.tar.gz: 77670b26e8ec2cfd5006e18560f93e69b27199ef2edcd4f2ec2fb5be0cd329218be354b26ae1fc1b47090793e2e8e987375cacd78daddf37ca8c96a520ca4d79
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — Unreleased
4
+
5
+ - Initial release: full Kit v4 API surface (subscribers, tags, custom fields,
6
+ forms, sequences, sequence emails, broadcasts, account, purchases, segments,
7
+ snippets, posts, email templates, webhooks, bulk operations).
8
+ - High-level `Kit.subscribe` / `Kit.tag` (by name, create-if-missing, cached) /
9
+ `Kit.unsubscribe` (by email, idempotent) conveniences.
10
+ - `Kit::Simulated` recording driver with an enforced surface-parity guarantee.
11
+ - Zero runtime dependencies.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mike D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # kit_api
2
+
3
+ A plain-Ruby client for the [Kit](https://kit.com) (formerly ConvertKit) **v4 API**. Zero runtime dependencies — `Net::HTTP` and stdlib JSON, nothing else — plus a first-class simulated driver so your tests never touch the network.
4
+
5
+ [![CI](https://github.com/miked-bro/kit_api/actions/workflows/ci.yml/badge.svg)](https://github.com/miked-bro/kit_api/actions/workflows/ci.yml)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ gem "kit_api"
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```ruby
17
+ Kit.configure do |config|
18
+ config.driver = :v4
19
+ config.api_key = "kit_..." # from Kit → Settings → Developer
20
+ # config.access_token = "..." # or an OAuth token instead
21
+ # config.logger = Logger.new($stdout)
22
+ end
23
+
24
+ # High-level conveniences for the common lifecycle:
25
+ Kit.subscribe(email: "sam@example.com", first_name: "Sam", fields: { plan: "pro" })
26
+ # => { subscriber_id: "123" }
27
+
28
+ Kit.tag(email: "sam@example.com", tag: "customer") # by tag NAME — created if missing
29
+ Kit.unsubscribe(email: "sam@example.com")
30
+ ```
31
+
32
+ Everything else lives on the client, one method per documented endpoint:
33
+
34
+ ```ruby
35
+ Kit.client.subscribers(status: "active", per_page: 100)
36
+ Kit.client.create_broadcast(subject: "News", content: "<p>Hi!</p>", ...)
37
+ Kit.client.add_subscriber_to_sequence(4, email_address: "sam@example.com")
38
+ ```
39
+
40
+ Or build a client directly, no global config: `Kit::Client.new(api_key: "kit_...")`.
41
+
42
+ ## The full surface
43
+
44
+ Methods return the parsed JSON body as-is (string keys); `204 No Content` returns `true`. Non-2xx responses raise `Kit::Error`, which carries `#status` and the raw `#body`.
45
+
46
+ | Resource | Methods |
47
+ | --- | --- |
48
+ | Subscribers | `subscribers` `subscriber` `create_subscriber` `update_subscriber` `unsubscribe_subscriber` `bulk_create_subscribers` `subscriber_stats` `subscriber_tags` `pin_subscriber_location` `update_subscriber_location` `delete_subscriber_location` |
49
+ | Tags | `tags` `create_tag` `update_tag` `tag_subscribers` `tag_subscriber` `untag_subscriber` `bulk_create_tags` `bulk_delete_tags` `bulk_tag_subscribers` `bulk_untag_subscribers` |
50
+ | Custom fields | `custom_fields` `create_custom_field` `update_custom_field` `delete_custom_field` `bulk_create_custom_fields` `bulk_update_subscriber_fields` |
51
+ | Forms | `forms` `form_subscribers` `add_subscriber_to_form` `bulk_add_subscribers_to_forms` |
52
+ | Sequences | `sequences` `sequence` `create_sequence` `update_sequence` `delete_sequence` `sequence_subscribers` `add_subscriber_to_sequence` `sequence_emails` `sequence_email` `create_sequence_email` `update_sequence_email` `delete_sequence_email` |
53
+ | Broadcasts | `broadcasts` `broadcast` `create_broadcast` `update_broadcast` `delete_broadcast` `broadcast_stats` `broadcasts_stats` `broadcast_clicks` |
54
+ | Account | `account` `account_colors` `update_account_colors` `creator_profile` `email_stats` `growth_stats` |
55
+ | Purchases | `purchases` `purchase` `create_purchase` |
56
+ | Webhooks | `webhooks` `webhook` `create_webhook` `update_webhook` `delete_webhook` `rotate_webhook_secret` `revoke_previous_webhook_secret` |
57
+ | And | `segments` · `snippets` `snippet` `create_snippet` `update_snippet` · `posts` `post` · `email_templates` |
58
+
59
+ Methods that reach a subscriber take either `email_address:` or `subscriber_id:`:
60
+
61
+ ```ruby
62
+ Kit.client.tag_subscriber(5, email_address: "sam@example.com")
63
+ Kit.client.tag_subscriber(5, subscriber_id: 9)
64
+ ```
65
+
66
+ ## Pagination
67
+
68
+ Every list endpoint is cursor-paginated. Pass the cursors straight through:
69
+
70
+ ```ruby
71
+ cursor = nil
72
+ loop do
73
+ page = Kit.client.subscribers(per_page: 1000, after: cursor)
74
+ page["subscribers"].each { |subscriber| ... }
75
+ break unless page.dig("pagination", "has_next_page")
76
+ cursor = page.dig("pagination", "end_cursor")
77
+ end
78
+ ```
79
+
80
+ ## Testing with the simulated driver
81
+
82
+ The simulated driver mirrors the real client's surface exactly (a test in this gem enforces it), records every call, and talks to no one:
83
+
84
+ ```ruby
85
+ # The default driver is already :simulated — no configuration needed in tests.
86
+ class FunnelTest < ActiveSupport::TestCase
87
+ setup { Kit::Simulated.reset! }
88
+
89
+ test "signing up subscribes the lead" do
90
+ post signups_path, params: { email: "sam@example.com" }
91
+
92
+ subscribe = Kit::Simulated.calls.find { |call| call[:op] == :subscribe }
93
+ assert_equal "sam@example.com", subscribe[:email]
94
+ end
95
+ end
96
+ ```
97
+
98
+ `Kit::Simulated.calls` is an array of `{ op:, **arguments }` hashes; `subscribe` returns deterministic ids (`"sim_<sha256 prefix>"`) so records that store a `subscriber_id` behave realistically.
99
+
100
+ ## Rails
101
+
102
+ ```ruby
103
+ # config/initializers/kit.rb
104
+ Kit.configure do |config|
105
+ config.driver = Rails.env.production? ? :v4 : :simulated
106
+ config.api_key = Rails.application.credentials.dig(:kit, :api_key)
107
+ config.logger = Rails.logger
108
+ end
109
+ ```
110
+
111
+ ## Good to know
112
+
113
+ - **No automatic retries.** Rate limits (120 requests/minute with an API key, 600 with OAuth) and transient failures raise `Kit::Error`; retry where you understand idempotency — e.g. `retry_on Kit::Error` in an Active Job.
114
+ - **OAuth-only endpoints.** Kit restricts the `bulk_*` methods and `create_purchase` to OAuth tokens; with an API key the API returns its own error.
115
+ - **Tag names are cached.** `Kit.tag`/`Kit.client.tag(email:, tag:)` resolves names to ids once per client. Renaming a tag in the Kit UI mid-process can leave a stale id; reconfigure (or build a fresh client) to flush.
116
+ - **Custom fields must exist.** Values in `fields:` only stick for custom fields already defined in your Kit account — create them in the UI or via `create_custom_field(label: "Plan")`.
117
+
118
+ ## License
119
+
120
+ [MIT](LICENSE.txt)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Accounts
6
+ def account
7
+ request(:get, "/account")
8
+ end
9
+
10
+ def account_colors
11
+ request(:get, "/account/colors")
12
+ end
13
+
14
+ # Replaces the whole palette (up to 10 hex codes) — include every color to keep.
15
+ def update_account_colors(colors)
16
+ request(:put, "/account/colors", body: { colors: colors })
17
+ end
18
+
19
+ def creator_profile
20
+ request(:get, "/account/creator_profile")
21
+ end
22
+
23
+ def email_stats
24
+ request(:get, "/account/email_stats")
25
+ end
26
+
27
+ def growth_stats(**params)
28
+ request(:get, "/account/growth_stats", params: params)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Broadcasts
6
+ def broadcasts(**params)
7
+ request(:get, "/broadcasts", params: params)
8
+ end
9
+
10
+ def broadcast(id)
11
+ request(:get, "/broadcasts/#{id}")
12
+ end
13
+
14
+ # send_at: nil leaves it a draft; subscriber_filter targets tags/segments.
15
+ def create_broadcast(**attributes)
16
+ request(:post, "/broadcasts", body: attributes)
17
+ end
18
+
19
+ def update_broadcast(id, **attributes)
20
+ request(:put, "/broadcasts/#{id}", body: attributes)
21
+ end
22
+
23
+ def delete_broadcast(id)
24
+ request(:delete, "/broadcasts/#{id}")
25
+ end
26
+
27
+ def broadcast_stats(id)
28
+ request(:get, "/broadcasts/#{id}/stats")
29
+ end
30
+
31
+ # Filters: sent_after/before, status.
32
+ def broadcasts_stats(**params)
33
+ request(:get, "/broadcasts/stats", params: params)
34
+ end
35
+
36
+ def broadcast_clicks(id, **params)
37
+ request(:get, "/broadcasts/#{id}/clicks", params: params)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module CustomFields
6
+ def custom_fields(**params)
7
+ request(:get, "/custom_fields", params: params)
8
+ end
9
+
10
+ def create_custom_field(label:)
11
+ request(:post, "/custom_fields", body: { label: label })
12
+ end
13
+
14
+ def update_custom_field(id, label:)
15
+ request(:put, "/custom_fields/#{id}", body: { label: label })
16
+ end
17
+
18
+ def delete_custom_field(id)
19
+ request(:delete, "/custom_fields/#{id}")
20
+ end
21
+
22
+ def bulk_create_custom_fields(custom_fields, callback_url: nil)
23
+ request(:post, "/bulk/custom_fields",
24
+ body: { custom_fields: custom_fields, callback_url: callback_url }.compact)
25
+ end
26
+
27
+ # Values are { subscriber_id:, subscriber_custom_field_id:, value: } triples.
28
+ def bulk_update_subscriber_fields(custom_field_values, callback_url: nil)
29
+ request(:post, "/bulk/custom_fields/subscribers",
30
+ body: { custom_field_values: custom_field_values, callback_url: callback_url }.compact)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module EmailTemplates
6
+ def email_templates(**params)
7
+ request(:get, "/email_templates", params: params)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Forms
6
+ def forms(**params)
7
+ request(:get, "/forms", params: params)
8
+ end
9
+
10
+ def form_subscribers(id, **params)
11
+ request(:get, "/forms/#{id}/subscribers", params: params)
12
+ end
13
+
14
+ def add_subscriber_to_form(form_id, email_address: nil, subscriber_id: nil, referrer: nil)
15
+ if subscriber_id
16
+ request(:post, "/forms/#{form_id}/subscribers/#{subscriber_id}", body: { referrer: referrer }.compact)
17
+ else
18
+ request(:post, "/forms/#{form_id}/subscribers",
19
+ body: { email_address: email_address, referrer: referrer }.compact)
20
+ end
21
+ end
22
+
23
+ # Additions are { form_id:, subscriber_id:, referrer: } objects.
24
+ def bulk_add_subscribers_to_forms(additions, callback_url: nil)
25
+ request(:post, "/bulk/forms/subscribers",
26
+ body: { additions: additions, callback_url: callback_url }.compact)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Posts
6
+ def posts(**params)
7
+ request(:get, "/posts", params: params)
8
+ end
9
+
10
+ def post(id)
11
+ request(:get, "/posts/#{id}")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Purchases
6
+ def purchases(**params)
7
+ request(:get, "/purchases", params: params)
8
+ end
9
+
10
+ def purchase(id)
11
+ request(:get, "/purchases/#{id}")
12
+ end
13
+
14
+ # OAuth only. An existing transaction_id appends products instead of duplicating.
15
+ def create_purchase(**attributes)
16
+ request(:post, "/purchases", body: { purchase: attributes })
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Segments
6
+ def segments(**params)
7
+ request(:get, "/segments", params: params)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Sequences
6
+ def sequences(**params)
7
+ request(:get, "/sequences", params: params)
8
+ end
9
+
10
+ def sequence(id)
11
+ request(:get, "/sequences/#{id}")
12
+ end
13
+
14
+ def create_sequence(name:, **attributes)
15
+ request(:post, "/sequences", body: { name: name, **attributes })
16
+ end
17
+
18
+ def update_sequence(id, **attributes)
19
+ request(:put, "/sequences/#{id}", body: attributes)
20
+ end
21
+
22
+ def delete_sequence(id)
23
+ request(:delete, "/sequences/#{id}")
24
+ end
25
+
26
+ def sequence_subscribers(id, **params)
27
+ request(:get, "/sequences/#{id}/subscribers", params: params)
28
+ end
29
+
30
+ def add_subscriber_to_sequence(sequence_id, email_address: nil, subscriber_id: nil)
31
+ if subscriber_id
32
+ request(:post, "/sequences/#{sequence_id}/subscribers/#{subscriber_id}")
33
+ else
34
+ request(:post, "/sequences/#{sequence_id}/subscribers", body: { email_address: email_address })
35
+ end
36
+ end
37
+
38
+ def sequence_emails(sequence_id, **params)
39
+ request(:get, "/sequences/#{sequence_id}/emails", params: params)
40
+ end
41
+
42
+ def sequence_email(sequence_id, id)
43
+ request(:get, "/sequences/#{sequence_id}/emails/#{id}")
44
+ end
45
+
46
+ def create_sequence_email(sequence_id, subject:, delay_value:, delay_unit:, **attributes)
47
+ request(:post, "/sequences/#{sequence_id}/emails",
48
+ body: { subject: subject, delay_value: delay_value, delay_unit: delay_unit, **attributes })
49
+ end
50
+
51
+ def update_sequence_email(sequence_id, id, **attributes)
52
+ request(:put, "/sequences/#{sequence_id}/emails/#{id}", body: attributes)
53
+ end
54
+
55
+ def delete_sequence_email(sequence_id, id)
56
+ request(:delete, "/sequences/#{sequence_id}/emails/#{id}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Snippets
6
+ def snippets(**params)
7
+ request(:get, "/snippets", params: params)
8
+ end
9
+
10
+ def snippet(id)
11
+ request(:get, "/snippets/#{id}")
12
+ end
13
+
14
+ # snippet_type: "inline" takes content:; "block" takes document_attributes:.
15
+ def create_snippet(name:, **attributes)
16
+ request(:post, "/snippets", body: { name: name, **attributes })
17
+ end
18
+
19
+ def update_snippet(id, **attributes)
20
+ request(:put, "/snippets/#{id}", body: attributes)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Subscribers
6
+ # Filters: email_address (exact), status, created_after/before,
7
+ # updated_after/before, sort_field, sort_order, include, slim.
8
+ def subscribers(**params)
9
+ request(:get, "/subscribers", params: params)
10
+ end
11
+
12
+ def subscriber(id)
13
+ request(:get, "/subscribers/#{id}")
14
+ end
15
+
16
+ # Upserts: an existing email_address gets its first_name updated.
17
+ def create_subscriber(email_address:, **attributes)
18
+ request(:post, "/subscribers", body: { email_address: email_address, **attributes })
19
+ end
20
+
21
+ def update_subscriber(id, **attributes)
22
+ request(:put, "/subscribers/#{id}", body: attributes)
23
+ end
24
+
25
+ def unsubscribe_subscriber(id)
26
+ request(:post, "/subscribers/#{id}/unsubscribe")
27
+ end
28
+
29
+ def bulk_create_subscribers(subscribers, callback_url: nil)
30
+ request(:post, "/bulk/subscribers", body: { subscribers: subscribers, callback_url: callback_url }.compact)
31
+ end
32
+
33
+ def subscriber_stats(id, **params)
34
+ request(:get, "/subscribers/#{id}/stats", params: params)
35
+ end
36
+
37
+ def subscriber_tags(id, **params)
38
+ request(:get, "/subscribers/#{id}/tags", params: params)
39
+ end
40
+
41
+ def pin_subscriber_location(id, **location)
42
+ request(:post, "/subscribers/#{id}/location", body: { location: location })
43
+ end
44
+
45
+ def update_subscriber_location(id, **location)
46
+ request(:patch, "/subscribers/#{id}/location", body: { location: location })
47
+ end
48
+
49
+ def delete_subscriber_location(id)
50
+ request(:delete, "/subscribers/#{id}/location")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Tags
6
+ def tags(**params)
7
+ request(:get, "/tags", params: params)
8
+ end
9
+
10
+ def create_tag(name)
11
+ request(:post, "/tags", body: { name: name })
12
+ end
13
+
14
+ def update_tag(id, name:)
15
+ request(:put, "/tags/#{id}", body: { name: name })
16
+ end
17
+
18
+ def tag_subscribers(id, **params)
19
+ request(:get, "/tags/#{id}/subscribers", params: params)
20
+ end
21
+
22
+ def tag_subscriber(tag_id, email_address: nil, subscriber_id: nil)
23
+ if subscriber_id
24
+ request(:post, "/tags/#{tag_id}/subscribers/#{subscriber_id}")
25
+ else
26
+ request(:post, "/tags/#{tag_id}/subscribers", body: { email_address: email_address })
27
+ end
28
+ end
29
+
30
+ def untag_subscriber(tag_id, email_address: nil, subscriber_id: nil)
31
+ if subscriber_id
32
+ request(:delete, "/tags/#{tag_id}/subscribers/#{subscriber_id}")
33
+ else
34
+ request(:delete, "/tags/#{tag_id}/subscribers", params: { email_address: email_address })
35
+ end
36
+ end
37
+
38
+ # Bulk endpoints need OAuth; taggings are { tag_id:, subscriber_id: } pairs.
39
+ def bulk_create_tags(tags, callback_url: nil)
40
+ request(:post, "/bulk/tags", body: { tags: tags, callback_url: callback_url }.compact)
41
+ end
42
+
43
+ def bulk_delete_tags(tags, callback_url: nil)
44
+ request(:delete, "/bulk/tags", body: { tags: tags, callback_url: callback_url }.compact)
45
+ end
46
+
47
+ def bulk_tag_subscribers(taggings, callback_url: nil)
48
+ request(:post, "/bulk/tags/subscribers", body: { taggings: taggings, callback_url: callback_url }.compact)
49
+ end
50
+
51
+ def bulk_untag_subscribers(taggings, callback_url: nil)
52
+ request(:delete, "/bulk/tags/subscribers", body: { taggings: taggings, callback_url: callback_url }.compact)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ class Client
5
+ module Webhooks
6
+ def webhooks(**params)
7
+ request(:get, "/webhook_endpoints", params: params)
8
+ end
9
+
10
+ def webhook(id)
11
+ request(:get, "/webhook_endpoints/#{id}")
12
+ end
13
+
14
+ # The signing secret comes back in plaintext only on this response.
15
+ def create_webhook(url:, events:, **attributes)
16
+ request(:post, "/webhook_endpoints", body: { url: url, events: events, **attributes })
17
+ end
18
+
19
+ def update_webhook(id, **attributes)
20
+ request(:put, "/webhook_endpoints/#{id}", body: attributes)
21
+ end
22
+
23
+ def delete_webhook(id)
24
+ request(:delete, "/webhook_endpoints/#{id}")
25
+ end
26
+
27
+ def rotate_webhook_secret(id, force: nil)
28
+ request(:post, "/webhook_endpoints/#{id}/rotate_secret", body: { force: force }.compact)
29
+ end
30
+
31
+ def revoke_previous_webhook_secret(id)
32
+ request(:post, "/webhook_endpoints/#{id}/revoke_previous_secret")
33
+ end
34
+ end
35
+ end
36
+ end
data/lib/kit/client.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ require_relative "client/accounts"
8
+ require_relative "client/broadcasts"
9
+ require_relative "client/custom_fields"
10
+ require_relative "client/email_templates"
11
+ require_relative "client/forms"
12
+ require_relative "client/posts"
13
+ require_relative "client/purchases"
14
+ require_relative "client/segments"
15
+ require_relative "client/sequences"
16
+ require_relative "client/snippets"
17
+ require_relative "client/subscribers"
18
+ require_relative "client/tags"
19
+ require_relative "client/webhooks"
20
+
21
+ module Kit
22
+ # The real Kit v4 API (https://developers.kit.com). One method per endpoint,
23
+ # named resources/resource(id)/create_resource/verb_resource; each returns
24
+ # the parsed JSON body as-is (204s return true). Pagination cursors pass
25
+ # straight through: subscribers(after: cursor, per_page: 100).
26
+ class Client
27
+ include Accounts, Broadcasts, CustomFields, EmailTemplates, Forms, Posts,
28
+ Purchases, Segments, Sequences, Snippets, Subscribers, Tags, Webhooks
29
+
30
+ def initialize(api_key: nil, access_token: nil, base_url: "https://api.kit.com/v4",
31
+ open_timeout: 10, read_timeout: 20)
32
+ raise Error, "Kit needs an api_key or an OAuth access_token" if api_key.nil? && access_token.nil?
33
+
34
+ @api_key = api_key
35
+ @access_token = access_token
36
+ @base_url = base_url
37
+ @open_timeout = open_timeout
38
+ @read_timeout = read_timeout
39
+ end
40
+
41
+ # High-level conveniences, composed from the endpoint methods below.
42
+
43
+ def subscribe(email:, first_name:, fields: {})
44
+ body = create_subscriber(email_address: email, first_name: first_name, fields: fields)
45
+ { subscriber_id: body.dig("subscriber", "id")&.to_s }
46
+ end
47
+
48
+ # Tags by name: resolved to an id once per client (created if missing) and
49
+ # cached for the life of the process. Reconfiguring flushes the cache.
50
+ def tag(email:, tag:)
51
+ tag_subscriber(tag_id(tag), email_address: email)
52
+ true
53
+ end
54
+
55
+ # The v4 API unsubscribes by subscriber id only, so look the id up first.
56
+ # An address Kit doesn't know is already unsubscribed — succeed quietly.
57
+ def unsubscribe(email:)
58
+ subscriber = subscribers(email_address: email).fetch("subscribers", []).first
59
+ return true unless subscriber
60
+
61
+ unsubscribe_subscriber(subscriber["id"])
62
+ end
63
+
64
+ private
65
+ def tag_id(name)
66
+ @tag_ids ||= {}
67
+ @tag_ids[name] ||= begin
68
+ existing, cursor = nil, nil
69
+ loop do
70
+ page = tags(per_page: 1000, after: cursor)
71
+ existing = page.fetch("tags", []).find { |t| t["name"] == name }
72
+ cursor = page.dig("pagination", "end_cursor")
73
+ break if existing || !page.dig("pagination", "has_next_page")
74
+ end
75
+ (existing || create_tag(name).fetch("tag"))["id"]
76
+ end
77
+ end
78
+
79
+ VERBS = {
80
+ get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put,
81
+ patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
82
+ }.freeze
83
+
84
+ def request(method, path, params: nil, body: nil)
85
+ uri = URI("#{@base_url}#{path}")
86
+ query = params&.compact
87
+ uri.query = URI.encode_www_form(query) unless query.nil? || query.empty?
88
+
89
+ req = VERBS.fetch(method).new(uri.request_uri)
90
+ if @access_token
91
+ req["Authorization"] = "Bearer #{@access_token}"
92
+ else
93
+ req["X-Kit-Api-Key"] = @api_key
94
+ end
95
+ req["Accept"] = "application/json"
96
+ if body
97
+ req["Content-Type"] = "application/json"
98
+ req.body = JSON.generate(body)
99
+ end
100
+
101
+ http = Net::HTTP.new(uri.host, uri.port)
102
+ http.use_ssl = uri.scheme == "https"
103
+ http.open_timeout = @open_timeout
104
+ http.read_timeout = @read_timeout
105
+
106
+ response = http.request(req)
107
+ unless response.code.start_with?("2")
108
+ raise Error.new("Kit API #{response.code}: #{response.body.to_s[0, 200]}",
109
+ status: response.code.to_i, body: response.body)
110
+ end
111
+
112
+ response.body.to_s.empty? ? true : JSON.parse(response.body)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Kit
6
+ # Dev/test driver: records every call, invents ids, talks to no one. Same
7
+ # public surface as Client (a test enforces parity), so code under test can
8
+ # assert on Kit::Simulated.calls instead of stubbing HTTP:
9
+ #
10
+ # Kit::Simulated.reset!
11
+ # Kit.subscribe(email: "sam@example.com", first_name: "Sam")
12
+ # Kit::Simulated.calls # => [{ op: :subscribe, email: "sam@example.com", ... }]
13
+ class Simulated
14
+ def self.calls = @calls ||= []
15
+ def self.reset! = @calls = []
16
+
17
+ PAGE = { "has_previous_page" => false, "has_next_page" => false,
18
+ "start_cursor" => nil, "end_cursor" => nil, "per_page" => 500 }.freeze
19
+
20
+ # High-level conveniences
21
+
22
+ def subscribe(email:, first_name:, fields: {})
23
+ record(:subscribe, email: email, first_name: first_name, fields: fields)
24
+ { subscriber_id: sim_id(email) }
25
+ end
26
+
27
+ def tag(email:, tag:)
28
+ record(:tag, email: email, tag: tag)
29
+ true
30
+ end
31
+
32
+ def unsubscribe(email:)
33
+ record(:unsubscribe, email: email)
34
+ true
35
+ end
36
+
37
+ # Subscribers
38
+
39
+ def subscribers(**params) = record_page(:subscribers, "subscribers", **params)
40
+ def subscriber(id) = record_one(:subscriber, "subscriber", id, id: id)
41
+
42
+ def create_subscriber(email_address:, **attributes)
43
+ record(:create_subscriber, email_address: email_address, **attributes)
44
+ { "subscriber" => { "id" => sim_id(email_address), "email_address" => email_address,
45
+ "state" => "active", **attributes.transform_keys(&:to_s) } }
46
+ end
47
+
48
+ def update_subscriber(id, **attributes) = record_one(:update_subscriber, "subscriber", id, id: id, **attributes)
49
+ def unsubscribe_subscriber(id) = record(:unsubscribe_subscriber, id: id)
50
+ def bulk_create_subscribers(subscribers, callback_url: nil) = record_bulk(:bulk_create_subscribers, subscribers: subscribers, callback_url: callback_url)
51
+ def subscriber_stats(id, **params) = record_one(:subscriber_stats, "subscriber", id, id: id, **params)
52
+ def subscriber_tags(id, **params) = record_page(:subscriber_tags, "tags", id: id, **params)
53
+ def pin_subscriber_location(id, **location) = record_one(:pin_subscriber_location, "subscriber", id, id: id, **location)
54
+ def update_subscriber_location(id, **location) = record_one(:update_subscriber_location, "subscriber", id, id: id, **location)
55
+ def delete_subscriber_location(id) = record(:delete_subscriber_location, id: id)
56
+
57
+ # Tags
58
+
59
+ def tags(**params) = record_page(:tags, "tags", **params)
60
+
61
+ def create_tag(name)
62
+ record(:create_tag, name: name)
63
+ { "tag" => { "id" => sim_id(name), "name" => name } }
64
+ end
65
+
66
+ def update_tag(id, name:) = record_one(:update_tag, "tag", id, id: id, name: name)
67
+ def tag_subscribers(id, **params) = record_page(:tag_subscribers, "subscribers", id: id, **params)
68
+
69
+ def tag_subscriber(tag_id, email_address: nil, subscriber_id: nil)
70
+ record(:tag_subscriber, tag_id: tag_id, email_address: email_address, subscriber_id: subscriber_id)
71
+ { "subscriber" => { "id" => subscriber_id || sim_id(email_address.to_s) } }
72
+ end
73
+
74
+ def untag_subscriber(tag_id, email_address: nil, subscriber_id: nil)
75
+ record(:untag_subscriber, tag_id: tag_id, email_address: email_address, subscriber_id: subscriber_id)
76
+ end
77
+
78
+ def bulk_create_tags(tags, callback_url: nil) = record_bulk(:bulk_create_tags, tags: tags, callback_url: callback_url)
79
+ def bulk_delete_tags(tags, callback_url: nil) = record_bulk(:bulk_delete_tags, tags: tags, callback_url: callback_url)
80
+ def bulk_tag_subscribers(taggings, callback_url: nil) = record_bulk(:bulk_tag_subscribers, taggings: taggings, callback_url: callback_url)
81
+ def bulk_untag_subscribers(taggings, callback_url: nil) = record_bulk(:bulk_untag_subscribers, taggings: taggings, callback_url: callback_url)
82
+
83
+ # Custom fields
84
+
85
+ def custom_fields(**params) = record_page(:custom_fields, "custom_fields", **params)
86
+
87
+ def create_custom_field(label:)
88
+ record(:create_custom_field, label: label)
89
+ { "custom_field" => { "id" => sim_id(label), "label" => label } }
90
+ end
91
+
92
+ def update_custom_field(id, label:) = record_one(:update_custom_field, "custom_field", id, id: id, label: label)
93
+ def delete_custom_field(id) = record(:delete_custom_field, id: id)
94
+ def bulk_create_custom_fields(custom_fields, callback_url: nil) = record_bulk(:bulk_create_custom_fields, custom_fields: custom_fields, callback_url: callback_url)
95
+ def bulk_update_subscriber_fields(custom_field_values, callback_url: nil) = record_bulk(:bulk_update_subscriber_fields, custom_field_values: custom_field_values, callback_url: callback_url)
96
+
97
+ # Forms
98
+
99
+ def forms(**params) = record_page(:forms, "forms", **params)
100
+ def form_subscribers(id, **params) = record_page(:form_subscribers, "subscribers", id: id, **params)
101
+
102
+ def add_subscriber_to_form(form_id, email_address: nil, subscriber_id: nil, referrer: nil)
103
+ record(:add_subscriber_to_form, form_id: form_id, email_address: email_address,
104
+ subscriber_id: subscriber_id, referrer: referrer)
105
+ { "subscriber" => { "id" => subscriber_id || sim_id(email_address.to_s) } }
106
+ end
107
+
108
+ def bulk_add_subscribers_to_forms(additions, callback_url: nil) = record_bulk(:bulk_add_subscribers_to_forms, additions: additions, callback_url: callback_url)
109
+
110
+ # Sequences
111
+
112
+ def sequences(**params) = record_page(:sequences, "sequences", **params)
113
+ def sequence(id) = record_one(:sequence, "sequence", id, id: id)
114
+
115
+ def create_sequence(name:, **attributes)
116
+ record(:create_sequence, name: name, **attributes)
117
+ { "sequence" => { "id" => sim_id(name), "name" => name } }
118
+ end
119
+
120
+ def update_sequence(id, **attributes) = record_one(:update_sequence, "sequence", id, id: id, **attributes)
121
+ def delete_sequence(id) = record(:delete_sequence, id: id)
122
+ def sequence_subscribers(id, **params) = record_page(:sequence_subscribers, "subscribers", id: id, **params)
123
+
124
+ def add_subscriber_to_sequence(sequence_id, email_address: nil, subscriber_id: nil)
125
+ record(:add_subscriber_to_sequence, sequence_id: sequence_id, email_address: email_address,
126
+ subscriber_id: subscriber_id)
127
+ { "subscriber" => { "id" => subscriber_id || sim_id(email_address.to_s) } }
128
+ end
129
+
130
+ def sequence_emails(sequence_id, **params) = record_page(:sequence_emails, "emails", sequence_id: sequence_id, **params)
131
+ def sequence_email(sequence_id, id) = record_one(:sequence_email, "email", id, sequence_id: sequence_id, id: id)
132
+
133
+ def create_sequence_email(sequence_id, subject:, delay_value:, delay_unit:, **attributes)
134
+ record(:create_sequence_email, sequence_id: sequence_id, subject: subject,
135
+ delay_value: delay_value, delay_unit: delay_unit, **attributes)
136
+ { "email" => { "id" => sim_id(subject), "sequence_id" => sequence_id, "subject" => subject } }
137
+ end
138
+
139
+ def update_sequence_email(sequence_id, id, **attributes) = record_one(:update_sequence_email, "email", id, sequence_id: sequence_id, id: id, **attributes)
140
+ def delete_sequence_email(sequence_id, id) = record(:delete_sequence_email, sequence_id: sequence_id, id: id)
141
+
142
+ # Broadcasts
143
+
144
+ def broadcasts(**params) = record_page(:broadcasts, "broadcasts", **params)
145
+ def broadcast(id) = record_one(:broadcast, "broadcast", id, id: id)
146
+
147
+ def create_broadcast(**attributes)
148
+ record(:create_broadcast, **attributes)
149
+ { "broadcast" => { "id" => sim_id(attributes.inspect), **attributes.transform_keys(&:to_s) } }
150
+ end
151
+
152
+ def update_broadcast(id, **attributes) = record_one(:update_broadcast, "broadcast", id, id: id, **attributes)
153
+ def delete_broadcast(id) = record(:delete_broadcast, id: id)
154
+ def broadcast_stats(id) = record_one(:broadcast_stats, "broadcast", id, id: id)
155
+ def broadcasts_stats(**params) = record_page(:broadcasts_stats, "broadcasts", **params)
156
+ def broadcast_clicks(id, **params) = record_one(:broadcast_clicks, "broadcast", id, id: id, **params)
157
+
158
+ # Account
159
+
160
+ def account = record_one(:account, "account", "sim_account")
161
+ def account_colors = (record(:account_colors); { "colors" => [] })
162
+ def update_account_colors(colors) = (record(:update_account_colors, colors: colors); { "colors" => colors })
163
+ def creator_profile = record_one(:creator_profile, "profile", "sim_profile")
164
+ def email_stats = record_one(:email_stats, "stats", "sim_stats")
165
+ def growth_stats(**params) = record_one(:growth_stats, "stats", "sim_stats", **params)
166
+
167
+ # Purchases
168
+
169
+ def purchases(**params) = record_page(:purchases, "purchases", **params)
170
+ def purchase(id) = record_one(:purchase, "purchase", id, id: id)
171
+
172
+ def create_purchase(**attributes)
173
+ record(:create_purchase, **attributes)
174
+ { "purchase" => { "id" => sim_id(attributes.inspect), **attributes.transform_keys(&:to_s) } }
175
+ end
176
+
177
+ # Segments, snippets, posts, email templates
178
+
179
+ def segments(**params) = record_page(:segments, "segments", **params)
180
+ def snippets(**params) = record_page(:snippets, "snippets", **params)
181
+ def snippet(id) = record_one(:snippet, "snippet", id, id: id)
182
+
183
+ def create_snippet(name:, **attributes)
184
+ record(:create_snippet, name: name, **attributes)
185
+ { "snippet" => { "id" => sim_id(name), "name" => name } }
186
+ end
187
+
188
+ def update_snippet(id, **attributes) = record_one(:update_snippet, "snippet", id, id: id, **attributes)
189
+ def posts(**params) = record_page(:posts, "posts", **params)
190
+ def post(id) = record_one(:post, "post", id, id: id)
191
+ def email_templates(**params) = record_page(:email_templates, "email_templates", **params)
192
+
193
+ # Webhooks
194
+
195
+ def webhooks(**params) = record_page(:webhooks, "webhook_endpoints", **params)
196
+ def webhook(id) = record_one(:webhook, "webhook_endpoint", id, id: id)
197
+
198
+ def create_webhook(url:, events:, **attributes)
199
+ record(:create_webhook, url: url, events: events, **attributes)
200
+ { "webhook_endpoint" => { "id" => sim_id(url), "url" => url, "events" => events, "secret" => "whsec_simulated" } }
201
+ end
202
+
203
+ def update_webhook(id, **attributes) = record_one(:update_webhook, "webhook_endpoint", id, id: id, **attributes)
204
+ def delete_webhook(id) = record(:delete_webhook, id: id)
205
+ def rotate_webhook_secret(id, force: nil) = record_one(:rotate_webhook_secret, "webhook_endpoint", id, id: id, force: force)
206
+ def revoke_previous_webhook_secret(id) = record_one(:revoke_previous_webhook_secret, "webhook_endpoint", id, id: id)
207
+
208
+ private
209
+ def record(op, **kwargs)
210
+ self.class.calls << { op: op, **kwargs }
211
+ Kit.config.logger&.info("[Kit simulated] #{op} #{kwargs.values.first}")
212
+ true
213
+ end
214
+
215
+ def record_page(op, key, **kwargs)
216
+ record(op, **kwargs)
217
+ { key => [], "pagination" => PAGE.dup }
218
+ end
219
+
220
+ def record_one(op, key, id, **kwargs)
221
+ record(op, **kwargs)
222
+ { key => { "id" => id } }
223
+ end
224
+
225
+ def record_bulk(op, **kwargs)
226
+ record(op, **kwargs.compact)
227
+ { "failures" => [] }
228
+ end
229
+
230
+ def sim_id(seed) = "sim_#{Digest::SHA256.hexdigest(seed.to_s)[0, 12]}"
231
+ end
232
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/kit.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kit/version"
4
+ require_relative "kit/client"
5
+ require_relative "kit/simulated"
6
+
7
+ # Kit (ConvertKit) v4 API client. Configure once, then call the high-level
8
+ # conveniences on the module or reach the full API surface via Kit.client.
9
+ #
10
+ # Kit.configure do |config|
11
+ # config.driver = :v4
12
+ # config.api_key = "kit_..."
13
+ # end
14
+ #
15
+ # Kit.subscribe(email: "sam@example.com", first_name: "Sam")
16
+ # Kit.client.broadcasts(per_page: 10)
17
+ module Kit
18
+ class Error < StandardError
19
+ attr_reader :status, :body
20
+
21
+ def initialize(message, status: nil, body: nil)
22
+ @status = status
23
+ @body = body
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ Config = Struct.new(:api_key, :access_token, :driver, :logger,
29
+ :open_timeout, :read_timeout, :base_url)
30
+
31
+ def self.config
32
+ @config ||= Config.new(nil, nil, :simulated, nil, 10, 20, "https://api.kit.com/v4")
33
+ end
34
+
35
+ def self.configure
36
+ yield config
37
+ @client = nil
38
+ end
39
+
40
+ # Plain ||= — concurrent first-touch can build a redundant client, which is
41
+ # harmless (stateless but for the tag-name cache). Configure at boot.
42
+ def self.client
43
+ @client ||= case config.driver
44
+ when :v4
45
+ Client.new(api_key: config.api_key, access_token: config.access_token,
46
+ base_url: config.base_url, open_timeout: config.open_timeout,
47
+ read_timeout: config.read_timeout)
48
+ when :simulated
49
+ Simulated.new
50
+ else
51
+ raise Error, "Unknown Kit driver: #{config.driver.inspect} (use :v4 or :simulated)"
52
+ end
53
+ end
54
+
55
+ def self.reset! = (@config = nil; @client = nil)
56
+
57
+ def self.subscribe(email:, first_name:, fields: {})
58
+ client.subscribe(email: email, first_name: first_name, fields: fields)
59
+ end
60
+
61
+ def self.tag(email:, tag:)
62
+ client.tag(email: email, tag: tag)
63
+ end
64
+
65
+ def self.unsubscribe(email:)
66
+ client.unsubscribe(email: email)
67
+ end
68
+ end
data/lib/kit_api.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is named kit_api ("kit" was taken on rubygems) but defines the Kit
4
+ # module. This shim keeps Bundler's default require working.
5
+ require_relative "kit"
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kit_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike D
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'A plain-Ruby client for the Kit (formerly ConvertKit) v4 API: subscribers,
13
+ tags, custom fields, forms, sequences, broadcasts, purchases, webhooks and more.
14
+ Net::HTTP and stdlib JSON only, plus a first-class simulated driver for tests.'
15
+ email:
16
+ - mojonickle@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - lib/kit.rb
25
+ - lib/kit/client.rb
26
+ - lib/kit/client/accounts.rb
27
+ - lib/kit/client/broadcasts.rb
28
+ - lib/kit/client/custom_fields.rb
29
+ - lib/kit/client/email_templates.rb
30
+ - lib/kit/client/forms.rb
31
+ - lib/kit/client/posts.rb
32
+ - lib/kit/client/purchases.rb
33
+ - lib/kit/client/segments.rb
34
+ - lib/kit/client/sequences.rb
35
+ - lib/kit/client/snippets.rb
36
+ - lib/kit/client/subscribers.rb
37
+ - lib/kit/client/tags.rb
38
+ - lib/kit/client/webhooks.rb
39
+ - lib/kit/simulated.rb
40
+ - lib/kit/version.rb
41
+ - lib/kit_api.rb
42
+ homepage: https://github.com/miked-bro/kit_api
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ source_code_uri: https://github.com/miked-bro/kit_api
47
+ changelog_uri: https://github.com/miked-bro/kit_api/blob/main/CHANGELOG.md
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.2'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 3.6.9
64
+ specification_version: 4
65
+ summary: Kit (ConvertKit) v4 API client with zero dependencies.
66
+ test_files: []