PaymentKit
Ruby HTTP client for the PaymentKit REST API.
This gem is a thin SDK: it authenticates requests, encodes JSON, follows redirects, retries transient failures, maps API errors, and exposes resource methods that return parsed JSON hashes.
Requirements
- Ruby
>= 3.2 activesupport >= 6.1(powers the webhook event bus)- Transport uses only stdlib (
net/http,json,openssl); Rails is optional and needed only for the mountable webhook engine
Installation
Add the gem to your Gemfile:
gem "payment_kit"
Then run:
bundle install
Or install it directly:
gem install payment_kit
Quick start
require "payment_kit"
PaymentKit.configure do |config|
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY") # st_prod_...
config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID") # acc_prod_...
end
client = PaymentKit::Client.new
customer = client.create_customer(
email: "customer@example.com",
first_name: "Jane",
last_name: "Smith",
business_name: "Acme Inc"
)
puts customer["id"] # => "cus_prod_..."
Configuration
Global configuration
PaymentKit.configure do |config|
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
config.account_id = ENV.fetch("PAYMENT_KIT_ACCOUNT_ID")
config.signing_secret = ENV["PAYMENT_KIT_SIGNING_SECRET"] # optional, for webhooks
config.api_host = "https://app.paymentkit.com/api" # default
config.open_timeout = 10 # seconds, default
config.read_timeout = 30 # seconds, default
config.max_retries = 2 # default
end
| Option | Required | Default | Description |
|---|---|---|---|
secret_key |
Yes* | — | Server secret token (st_prod_...). Never expose in browsers. |
account_id |
Yes* | — | Account external id (acc_prod_...) used in the API path. |
api_host |
No | https://app.paymentkit.com/api |
API host root (without account id). |
base_url |
No | — | Full base URL override (https://host/api/{account_id}). Skips account_id when set. |
open_timeout |
No | 10 |
TCP open timeout (seconds). |
read_timeout |
No | 30 |
Response read timeout (seconds). |
max_retries |
No | 2 |
Retries for transient HTTP statuses. |
signing_secret |
No | — | Webhook signing secret (whsec_...). |
*Required unless you pass them (or base_url) when constructing Client.
Reset configuration in tests:
PaymentKit.reset_configuration!
Base URL resolution
Requests are sent to:
{base_url}{path}
Where base_url is resolved as:
- Explicit
base_urlif set (trailing slash removed), or {api_host}/{account_id}
Examples:
# Production-style
PaymentKit.configure do |c|
c.secret_key = "st_prod_..."
c.account_id = "acc_prod_abc"
end
# => https://app.paymentkit.com/api/acc_prod_abc
# Custom host
PaymentKit.configure do |c|
c.secret_key = "st_prod_..."
c.account_id = "acc_prod_abc"
c.api_host = "https://app.paymentkit.com/api"
end
# Full override (useful in tests)
client = PaymentKit::Client.new(
secret_key: "st_test",
base_url: "https://api.test/acc"
)
Per-client configuration (recommended for multi-account)
Prefer explicit credentials on the client when serving multiple accounts:
client = PaymentKit::Client.new(
secret_key: ENV.fetch("PAYMENT_KIT_SECRET_KEY"),
account_id: ENV.fetch("PAYMENT_KIT_ACCOUNT_ID"),
open_timeout: 5,
read_timeout: 20,
max_retries: 3
)
client.base_url
# => "https://app.paymentkit.com/api/acc_prod_..."
Keyword arguments override the global PaymentKit.configuration for that instance only.
Rails initializer example
# config/initializers/payment_kit.rb
PaymentKit.configure do |config|
config.secret_key = Rails.application.credentials.dig(:payment_kit, :secret_key)
config.account_id = Rails.application.credentials.dig(:payment_kit, :account_id)
config.signing_secret = Rails.application.credentials.dig(:payment_kit, :signing_secret)
end
Client overview
PaymentKit::Client is the only network entry point.
- Auth:
Authorization: Bearer {secret_key} - Bodies: JSON (
Content-Type: application/json) - Responses: parsed JSON
Hash(orArrayfor auto-paginated lists) - Writes:
POST,PUTandPATCHsend an automaticIdempotency-Key(UUID) unless you pass one - Redirects: follows HTTP
307/308(PaymentKit path canonicalization) - Retries:
408,429and5xxwith exponential backoff;409only when PaymentKit marks it retryable - Escape hatch:
raw_requestfor endpoints the gem does not wrap
client = PaymentKit::Client.new
Resource API
All methods return parsed JSON. Pass request bodies and query params as hashes
(symbol or string keys are fine). Every write method also accepts an
idempotency_key: — see Idempotency.
Paths below are relative to the account base URL
({api_host}/{account_id}), matching the
PaymentKit API reference.
Method index
| Method | HTTP | Path |
|---|---|---|
create_customer |
POST |
/customers/ |
retrieve_customer |
GET |
/customers/{id} |
update_customer |
PUT |
/customers/{id} |
list_customers |
GET |
/customers/ |
set_credit_balance |
PATCH |
/customers/{id}/credit-balance |
create_balance_transaction (deprecated) |
PATCH |
/customers/{id}/credit-balance |
create_credit_note |
POST |
/customers/{id}/credit-notes |
list_credit_notes |
GET |
/customers/{id}/credit-notes |
create_subscription |
POST |
/subscriptions |
retrieve_subscription |
GET |
/subscriptions/{id} |
list_subscriptions |
GET |
/subscriptions |
update_subscription |
PATCH |
/subscriptions/{id} |
update_subscription_items |
PATCH |
/subscriptions/{id}/items |
cancel_subscription |
POST |
/subscriptions/{id}/cancel |
schedule_cancellation |
POST |
/subscriptions/{id}/schedule-cancellation |
cancel_scheduled_cancellation |
DELETE |
/subscriptions/{id}/scheduled-cancellation |
pause_subscription |
POST |
/subscriptions/{id}/pause |
cancel_scheduled_pause |
DELETE |
/subscriptions/{id}/scheduled-pause |
resume_subscription |
POST |
/subscriptions/{id}/resume |
reschedule_billing |
POST |
/subscriptions/{id}/reschedule-billing |
renew_subscription |
POST |
/subscriptions/{id}/renew |
cancel_pending_change |
DELETE |
/subscriptions/{id}/pending-change |
change_plan (deprecated) |
POST |
/subscriptions/{id}/change-plan |
create_change_request |
POST |
/subscriptions/{id}/change-requests |
retrieve_change_request |
GET |
/subscriptions/{id}/change-requests/{request_id} |
active_change_request |
GET |
/subscriptions/{id}/change-requests/active |
add_change_request_changes |
PATCH |
/subscriptions/{id}/change-requests/{request_id} |
preview_change_request |
POST |
/subscriptions/{id}/change-requests/{request_id}/preview |
apply_change_request |
POST |
/subscriptions/{id}/change-requests/{request_id}/apply |
cancel_change_request |
DELETE |
/subscriptions/{id}/change-requests/{request_id} |
apply_subscription_changes |
POST |
/subscriptions/{id}/change-requests/apply |
create_invoice |
POST |
/invoices/ |
retrieve_invoice |
GET |
/invoices/{id} |
list_invoices |
GET |
/invoices/ |
retrieve_invoice_pdf |
GET |
/invoices/{id}/pdf |
finalize_invoice |
POST |
/invoices/{id}/finalize |
pay_invoice |
POST |
/invoices/{id}/collect |
void_invoice |
POST |
/invoices/{id}/void |
mark_invoice_uncollectible |
POST |
/invoices/{id}/mark-uncollectible |
bill_pending_items |
POST |
/invoices/bill-pending-items |
create_invoice_item |
POST |
/invoice-items/ |
retrieve_invoice_item |
GET |
/invoice-items/{id} |
list_invoice_items |
GET |
/invoice-items/ |
update_invoice_item |
PATCH |
/invoice-items/{id} |
create_payment_intent |
POST |
/payments/intents/ |
retrieve_payment_intent |
GET |
/payments/intents/{id} |
list_payment_intents |
GET |
/payments/intents/ |
list_refunds_by_intent |
GET |
/payments/refunds/by_intent/{id} |
list_attempts_by_intent |
GET |
/payments/processor_attempts/by_intent/{id} |
create_payment_method |
POST |
/payments/payment_methods/ |
retrieve_payment_method |
GET |
/payments/payment_methods/{id} |
update_payment_method |
PUT |
/payments/payment_methods/{id} |
deactivate_payment_method |
PUT |
/payments/payment_methods/{id} |
detach_payment_method |
DELETE |
/payments/payment_methods/{id} |
create_checkout_session |
POST |
/checkout-sessions |
retrieve_checkout_session |
GET |
/checkout-sessions/{id} |
list_products |
GET |
/products/ |
retrieve_product |
GET |
/products/{id} |
create_product |
POST |
/products/ |
update_product |
PATCH |
/products/{id} |
list_product_prices |
GET |
/products/{id}/prices |
list_prices |
GET |
/prices/ |
retrieve_price |
GET |
/prices/{id} |
create_price |
POST |
/prices/ |
Customers
customer = client.create_customer(
email: "customer@example.com",
first_name: "Jane",
last_name: "Smith",
business_name: "Acme Inc",
phone: "+15550100",
billing_email: "ap@acme.test",
currency: "USD",
language: "en",
address: { line1: "1 Market St", city: "San Francisco", country: "US", postal_code: "94105" },
tax_ids: ["EU372009832"],
metadata: { plan_tier: "enterprise" }
)
customer = client.retrieve_customer("cus_123")
# PUT — only the fields you send are changed
customer = client.update_customer("cus_123",
email: "new@example.com",
metadata: { plan_tier: "pro" }
)
customers = client.list_customers(limit: 50) # auto-paginated Array
Customer credit
set_credit_balance sets an absolute target balance for one currency —
PaymentKit issues or voids credit to reach it. It is not a delta. Balances are
tracked independently per currency, and amount_atom is in the smallest
currency unit.
# Make the customer's USD balance exactly 5000 atoms ($50.00)
balance = client.set_credit_balance("cus_123", amount_atom: 5000, currency: "USD")
# => { "amount_atom" => 5000, "currency" => "USD" }
To add credit incrementally, append a credit note. reason is required and
must be one of proration_excess, manual_adjustment, auto_apply or
debit_settlement. Always pass a stable idempotency key: a double submit issues
two notes.
note = client.create_credit_note(
"cus_123",
{
amount_atom: 2500,
currency: "USD",
reason: "manual_adjustment",
memo: "Goodwill credit for billing error",
invoice_id: "inv_123" # optional; otherwise a paid companion invoice is created
},
idempotency_key: "credit-overcharge-918"
)
notes = client.list_credit_notes("cus_123", currency: "USD")
PaymentKit draws credit down automatically when an invoice is collected; you do not apply it manually.
create_balance_transaction is deprecated. It hits the same endpoint, forwards
to set_credit_balance and warns — the old name implied delta semantics, but the
endpoint sets an absolute target balance:
# Deprecated; identical to set_credit_balance("cus_123", ...)
client.create_balance_transaction("cus_123", amount_atom: 5000, currency: "USD")
Subscriptions
customer_id, currency, billing_interval, billing_interval_count,
period_start and collection_method are required. Each entry in items is a
price_id plus quantity.
subscription = client.create_subscription(
customer_id: "cus_123",
currency: "USD",
billing_interval: "month", # day | week | month | year
billing_interval_count: 1,
period_start: "2026-02-01T00:00:00Z",
collection_method: "charge_automatically", # or send_invoice
processor_id: "proc_live_abc", # falls back to the account default
items: [
{ price_id: "price_monthly_pro", quantity: 1 },
{ price_id: "price_addon_seats", quantity: 5 }
],
trial_start: "2026-02-01T00:00:00Z",
trial_end: "2026-02-15T00:00:00Z",
net_d: 30, # payment terms in days
coupon_id: "coup_welcome20",
total_billing_cycles: 12 # auto-cancel after N cycles
)
subscription = client.retrieve_subscription("sub_123")
subscriptions = client.list_subscriptions(customer_id: "cus_123")
# Cancel at period end (and undo it) via the base update endpoint
client.update_subscription("sub_123", cancel_at_period_end: true)
client.update_subscription("sub_123", cancel_at_period_end: false)
Line-item changes go through the dedicated items endpoint, where
proration_behavior is required (always_invoice, create_prorations or
none). This is charge-first, so pass a stable idempotency key.
client.update_subscription_items("sub_123",
{
proration_behavior: "always_invoice",
items: [
{ id: "si_plan", price_id: "price_monthly_pro", quantity: 2 }
]
},
idempotency_key: "seat-change-42-v3"
)
Cancelling, pausing and rescheduling
# Immediate cancel. refund_option: none | full | prorated | cancel_unpaid
client.cancel_subscription("sub_123", refund_option: "prorated")
# Calculate the refund without executing
preview = client.cancel_subscription("sub_123", refund_option: "prorated", is_preview: true)
# Cancel on a specific date instead, then undo it
client.schedule_cancellation("sub_123", cancel_at: "2026-06-01T00:00:00Z", refund_option: "none")
client.cancel_scheduled_cancellation("sub_123")
# Pause now, or at period end. pause_for_cycles and resumption_date are
# mutually exclusive ways to schedule the auto-resume.
client.pause_subscription("sub_123", pause_behavior: "pause_immediately", pause_for_cycles: 2)
client.pause_subscription("sub_123", pause_behavior: "pause_at_end")
client.cancel_scheduled_pause("sub_123") # removes a pending pause_at_end
client.resume_subscription("sub_123")
client.renew_subscription("sub_123")
client.reschedule_billing("sub_123",
next_billing_date: "2026-03-15T00:00:00Z",
create_proration: true,
is_preview: false
)
# Cancels a scheduled (period-end) plan change before it executes
client.cancel_pending_change("sub_123")
Change requests (recommended for plan changes)
A create → add changes → preview → apply workflow that shows exact proration
before committing, and collects payment before modifying the subscription. Only
one active (draft/ready) request may exist per subscription; creating a
second raises PaymentKit::ConflictError.
request = client.create_change_request("sub_123",
reason: "Upgrade to annual plan",
expires_in_hours: 24
)
# Append item, coupon and balance changes. Callable repeatedly; each call
# appends. Doing this on a `ready` request reverts it to `draft`.
client.add_change_request_changes("sub_123", request["id"],
item_changes: [
{ action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" },
{ action: "add", price_id: "price_addon_support", quantity: 1, apply_at_end: false },
{ action: "drop", item_id: "si_legacy_addon" }
],
coupon_changes: [
{ action: "add", coupon_id: "coup_welcome20" }
],
trial_behavior: "preserve" # or end_now
)
# Pure computation: returns proration amounts and the execution plan, → ready
preview = client.preview_change_request("sub_123", request["id"])
preview["preview"]["invoice_total_atom"]
# Charge-first execution. A decline raises PaymentKit::CardError (402).
client.apply_change_request("sub_123", request["id"],
idempotency_key: "cr-#{request["id"]}"
)
request = client.retrieve_change_request("sub_123", request["id"])
active = client.active_change_request("sub_123") # nil when none is pending
client.cancel_change_request("sub_123", request["id"])
One-step shortcut — create, add changes, preview and apply in a single call:
client.apply_subscription_changes("sub_123",
{
item_changes: [
{ action: "update", item_id: "si_monthly_plan", price_id: "price_annual_plan" }
],
reason: "Upgrade to annual plan",
payment_method_id: "pm_123"
},
idempotency_key: "upgrade-#{user_id}-v1"
)
Legacy plan change (deprecated)
PaymentKit documents change-plan as the legacy single-call endpoint, planned
for deprecation. Prefer the change-request workflow above, or
apply_subscription_changes. Kept for existing integrations:
client.change_plan("sub_123",
reason: "Billing interval change to year",
proration_behavior: "always_invoice",
effective_at: "immediate",
items: [
{ action: "update", subscription_item_id: "si_plan",
new_price_id: "price_annual_plan", quantity: 1 }
]
)
Invoices
Invoices are created in draft. Each line item takes either an amount, a
unit_amount × quantity, or a catalog price_id.
invoice = client.create_invoice(
customer_id: "cus_123",
currency: "USD",
issued_at: "2026-02-01T00:00:00Z",
description: "Setup fee — Enterprise onboarding",
collection_method: "charge_automatically",
items: [
{ description: "Enterprise onboarding", quantity: 1, amount: 500.00 },
{ description: "Custom integration", quantity: 4, unit_amount: 150.00 },
{ price_id: "price_support_plan", quantity: 1 }
]
)
# Draft → Open: locks amounts, optionally generates the PDF and emails it
invoice = client.finalize_invoice(invoice["id"])
# Attempts collection; finalizes the invoice first when still draft.
# A decline raises PaymentKit::CardError and leaves the invoice payable.
result = client.pay_invoice(invoice["id"], payment_method_id: "pm_123")
result["invoice_status"] # => "paid"
invoice = client.retrieve_invoice("inv_123", expand: "custom_fields")
invoices = client.list_invoices(customer_id: "cus_123", status: "open")
# Poll while status is "generating"
pdf = client.retrieve_invoice_pdf("inv_123")
pdf["pdf_url"] if pdf["status"] == "available"
client.void_invoice("inv_123")
# Only permitted from OPEN or PAST_DUE; other states return 422
client.mark_invoice_uncollectible("inv_123")
Sweep floating (pending) items into standalone invoices — one per currency — finalize them and attempt collection immediately, without waiting for renewal. Duplicate requests create duplicate invoices, so a stable key is essential.
result = client.bill_pending_items(
{
customer_id: "cus_123", # required
subscription_id: "sub_123", # optional narrowing
currency: "USD",
collection_method: "charge_automatically",
description: "July usage",
tax_amount_atom: 0
},
idempotency_key: "bill-cus_123-2026-07"
)
result["invoices"].each { |inv| puts "#{inv["currency"]}: #{inv["items_swept"]} items" }
A renewal running concurrently raises PaymentKit::ConflictError (409).
Invoice items
Floating items attach to a subscription and are collected at the next renewal
(or on demand via bill_pending_items). The subscription must be active or
trialing, and the customer_id must match it.
# With a catalog price
item = client.create_invoice_item(
customer_id: "cus_123",
subscription_id: "sub_123",
price_id: "price_sms_usage",
quantity: 150,
description: "SMS charges — July 2026 (150 messages)"
)
# With a custom amount
item = client.create_invoice_item(
customer_id: "cus_123",
subscription_id: "sub_123",
amount: 75.00,
description: "Custom setup fee"
)
item = client.retrieve_invoice_item(item["id"])
item = client.update_invoice_item(item["id"], quantity: 200, description: "SMS charges — revised")
items = client.list_invoice_items(subscription_id: "sub_123", status: "floating")
Payment intents
Low-level charge control. Amounts are in atomic units.
intent = client.create_payment_intent(
customer_id: "cus_123",
amount_atom: 2500,
currency: "USD",
payment_method_id: "pm_123",
processor_id: "proc_live_abc",
metadata: { order_id: "ord_9" }
)
intent = client.retrieve_payment_intent("pi_123", expand: "checkout_attempt")
intents = client.list_payment_intents(customer_id: "cus_123", limit: 50)
refunds = client.list_refunds_by_intent("pi_123")
attempts = client.list_attempts_by_intent("pi_123")
Payment methods
pm = client.create_payment_method(customer_id: "cus_123", provider_type: "card")
pm = client.retrieve_payment_method("pm_123")
pm = client.update_payment_method("pm_123", metadata: { label: "primary" })
# Documented way to take a card out of use (PUT is_active: false)
client.deactivate_payment_method("pm_123")
# Outright delete. PaymentKit documents deletion only on the customer-portal
# surface, so if your account returns 404/405 use deactivate_payment_method.
client.detach_payment_method("pm_123")
Checkout sessions
Hosted collection. The response carries the secure_token used to initialise
PaymentKit.js or redirect to the hosted page.
session = client.create_checkout_session(
customer_id: "cus_123", # optional; pre-fills the customer
line_items: [{ price_id: "price_123", quantity: 1 }],
success_url: "https://example.com/success",
return_url: "https://example.com/cancel",
expires_in_hours: 24,
promotion_code: "LAUNCH20",
custom_fields: { internal_ref: "ord_9" }
)
session["secure_token"]
session = client.retrieve_checkout_session(session["id"])
Catalog
product = client.create_product(
name: "Pro plan",
description: "Everything in Starter, plus priority support",
is_active: true,
metadata: { tier: "pro" }
)
product = client.retrieve_product("prod_123")
product = client.update_product("prod_123", is_active: false, default_price_id: "price_123")
products = client.list_products(limit: 50)
prices = client.list_product_prices("prod_123")
price = client.create_price(
product_id: "prod_123",
currency: "USD",
unit_amount_atom: 2500,
pricing_type: "recurring",
billing_scheme: "per_unit",
recurring_interval: "month",
recurring_interval_count: 1,
trial_days: 14
)
price = client.retrieve_price("price_123")
prices = client.list_prices(limit: 50)
List pagination
Every list_* helper auto-paginates PaymentKit’s offset/limit envelope:
{ "items": [...], "total": 150, "has_more": true }
and return a flat Ruby Array of item hashes. PaymentKit defaults limit to 50
and caps it at 100; the helpers keep requesting pages until has_more is false.
products = client.list_products(limit: 50)
products.each { |product| puts product["id"] }
# Filters are forwarded as query params
invoices = client.list_invoices(customer_id: "cus_123", status: "open")
Use raw_request when you need a single page rather than the whole collection.
Webhooks and event bus
Inbound webhooks are verified, then fanned out synchronously via
ActiveSupport::Notifications. The gem does not persist events, enqueue jobs,
or retry deliveries — those remain host responsibilities. Deduplication is a host
responsibility too, but the gem provides the event_retriever hook for it
(see Deduplicating redeliveries).
Multiple signing secrets are supported and tried in order, which is what
PaymentKit's roll-secret grace period requires: during rotation both the old
and new secret are live.
Configure signing secrets
PaymentKit.configure do |config|
config.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET") # whsec_...
# or multiple secrets (tried in order):
# config.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
end
# Module-level accessors also work:
PaymentKit.signing_secret = ENV.fetch("PAYMENT_KIT_SIGNING_SECRET")
PaymentKit.signing_secrets = [ENV["PAYMENT_KIT_SIGNING_SECRET"], ENV["PAYMENT_KIT_SIGNING_SECRET_OLD"]]
Subscribe to events
PaymentKit.subscribe "invoice.paid" do |event|
# event is a Hash, e.g. { "id" => "evt_...", "type" => "invoice.paid", ... }
# Prefer enqueueing work here; slow handlers delay the webhook HTTP response.
end
PaymentKit.subscribe "invoice.", InvoiceHandler.new # prefix match; #call(event)
PaymentKit.all { |event| Rails.logger.info(event["type"]) }
PaymentKit.event_filter = lambda do |event|
# return event to dispatch, or nil to ignore (still a successful verify path)
event["type"] == "ping" ? nil : event
end
configure accepts two block shapes. A block that takes an argument receives the
configuration object; a block that takes none is evaluated against the module,
which reads better for registering subscribers in an initializer:
PaymentKit.configure do |config| # settings
config.secret_key = ENV.fetch("PAYMENT_KIT_SECRET_KEY")
end
PaymentKit.configure do # subscriber DSL
subscribe("invoice.paid") { |event| InvoicePaidJob.perform_later(event["id"]) }
all { |event| Rails.logger.info(event["type"]) }
end
Process a webhook (non-Rails or custom controller)
payload = request.body.read
signature = request.headers["X-Webhook-Signature"]
begin
event = PaymentKit.process_webhook(payload, signature)
# verified + instrumented; subscribers already ran
rescue PaymentKit::SignatureVerificationError
head :unauthorized # signature missing or invalid
rescue PaymentKit::InvalidRequestError
head :bad_request # verified, but the body is not JSON
end
Low-level verify without dispatch (existing Client API, preserved):
event = client.verify_webhook(payload, signature)
# or:
event = PaymentKit::Webhook.construct_event(payload, signature, PaymentKit.signing_secrets)
PaymentKit.instrument(event)
Rails Engine (optional)
When Rails is loaded, mount the engine to get POST / → verify → instrument → 200:
# config/routes.rb
mount PaymentKit::Engine, at: "/payment_kit"
Point PaymentKit’s webhook URL at https://your.app/payment_kit.
PaymentKit treats 4xx as a permanent failure and retries 5xx/timeouts five
times over roughly 27 hours, so the controller maps failures deliberately:
| Outcome | Status | Retried by PaymentKit |
|---|---|---|
| Bad or missing signature | 401 |
No |
| Verified but unparseable body | 400 |
No |
Subscriber raised, no error_handler |
500 |
Yes |
Subscriber raised, error_handler set |
200 |
No |
Endpoints must respond within 30 seconds, so subscribers should enqueue work rather than perform it inline.
Deduplicating redeliveries
event_retriever runs after verification and before dispatch. Return the event
to continue, or nil to drop it. PaymentKit redelivers on retry, so dedupe here
on the event id — the same value it sends in the X-Webhook-Event-Id header:
PaymentKit.event_retriever = lambda do |event|
key = "payment_kit:webhook:#{event["id"]}"
Sidekiq.redis { |r| r.set(key, "1", nx: true, ex: 3.days.to_i) } ? event : nil
end
process_webhook returns nil when the retriever drops a delivery.
Reporting subscriber failures
Without an error_handler, a raising subscriber returns 500 and PaymentKit
retries. Set one to report the exception and answer 200 instead, which is the
right choice when subscribers only enqueue background work:
PaymentKit.error_handler = ->(exception, _request) { Sentry.capture_exception(exception) }
error_handler is request-scoped: it fires once, after the fan-out has already
failed. For per-subscriber isolation use subscriber_error_handler, which wraps
each subscriber individually:
PaymentKit.subscriber_error_handler = lambda do |exception, event|
Sentry.capture_exception(exception, extra: { event_id: event["id"] })
end
This matters because ActiveSupport::Notifications runs the remaining
subscribers when one raises but still re-raises afterwards (aggregating into
ActiveSupport::Notifications::InstrumentationSubscriberError when several
fail). Without a handler the delivery fails and PaymentKit redelivers the whole
event, re-running the subscribers that already succeeded. With one set, the
failure is reported and the delivery is acknowledged.
Error handling
All errors inherit from PaymentKit::Error and may expose:
status— HTTP statusbody— raw response bodyrequest_id— from the RFC 7807 payload, falling back to theRequest-Idheadererror_code/retryable?— set on transient failures such asinvoice_lockedproblemanderror["..."]— any RFC 7807 extension member (invoice_id,subscription_id, …)
| Exception | Typical cause |
|---|---|
PaymentKit::AuthenticationError |
Missing/invalid key, HTTP 401 |
PaymentKit::PermissionError |
HTTP 403 — valid key, but not allowed on this resource |
PaymentKit::SignatureVerificationError |
Webhook signature missing or invalid |
PaymentKit::InvalidRequestError |
Missing account_id, HTTP 400/404/422, unparseable webhook body |
PaymentKit::CardError |
HTTP 402 — payment declined; the invoice stays payable |
PaymentKit::ConflictError |
HTTP 409 — clashes with current resource state |
PaymentKit::RateLimitError |
HTTP 429 |
PaymentKit::APIError |
Other HTTP errors (including exhausted 5xx retries) |
PaymentKit::APIConnectionError |
Timeouts, connection refused/reset, DNS failures |
begin
client.create_subscription(params)
rescue PaymentKit::ConflictError => e
retry if e.retryable? # e.g. error_code == "invoice_locked"
warn "Conflict on invoice #{e.invoice_id}: #{e.}"
rescue PaymentKit::CardError => e
warn "Declined: #{e.}"
rescue PaymentKit::InvalidRequestError => e
warn "Bad request (#{e.status}): #{e.} request_id=#{e.request_id}"
rescue PaymentKit::APIConnectionError => e
warn "Network problem: #{e.}"
rescue PaymentKit::Error => e
warn "PaymentKit error: #{e.}"
end
PermissionError and SignatureVerificationError both subclass
AuthenticationError, so existing rescue PaymentKit::AuthenticationError
blocks keep catching them.
API errors follow RFC 7807 problem
details (title, detail, request_id). Validation detail arrays are flattened
into readable field messages.
For compatibility with applications written against a nested error namespace,
PaymentKit::Client::AuthenticationError and friends resolve to the same classes.
Idempotency
POST,PUTandPATCHautomatically send anIdempotency-Keyheader (random UUID), and the same key is reused across the gem's internal retries so a retried write cannot double-charge.- A random key only protects a single call. For operations that must not
double-bill across process-level retries —
bill_pending_items,create_credit_note, charge-firstupdate_subscription_itemsandapply_change_request— pass your own stable key:
client.bill_pending_items(params, idempotency_key: "dispatch-#{user_id}-#{timestamp}")
client.update_subscription_items("sub_1", { items: [...], proration_behavior: "always_invoice" },
idempotency_key: "plan-change-#{user_id}-#{version}")
Every write method accepts the body positionally or as keywords, with an optional
idempotency_key: alongside:
client.create_customer(email: "a@b.com")
client.create_customer({ email: "a@b.com" }, idempotency_key: "signup-42")
client.create_customer(email: "a@b.com", idempotency_key: "signup-42")
Retries
408, 429 and 5xx are always retried with exponential backoff. 409 is
retried only when PaymentKit flags it (retryable: true, or a known
side-effect-free error_code such as invoice_locked) — other conflicts, like
creating a second active change request, fail immediately.
A numeric Retry-After response header takes precedence over the backoff curve,
capped at PaymentKit::Client::MAX_RETRY_DELAY (32s) so a bad header cannot park
a request indefinitely.
Instrumentation
request_begin and request_end hooks wrap every outbound API call. They are
deliberately separate from the webhook event bus, so API traffic is never
delivered to PaymentKit.all subscribers:
PaymentKit::Instrumentation.subscribe(:request_end) do |event|
StatsD.timing(
"payment_kit.request",
event.duration * 1000,
tags: ["method:#{event.method}", "path:#{event.path}", "status:#{event.status}"]
)
Rails.logger.warn("PaymentKit retried #{event.path} #{event.num_retries}x") if event.num_retries.positive?
end
request_end fires once per logical call — after retries, on both the success
and failure paths — and carries method, path, status, duration,
num_retries and request_id. A raising subscriber warns on stderr and never
breaks the API call. subscribe returns a name you can pass to unsubscribe.
Calling unwrapped endpoints
raw_request reaches any PaymentKit endpoint this gem does not wrap yet, reusing
the same auth, retry, idempotency and error mapping:
client.raw_request(:get, "/payment-links", params: { limit: 10 })
client.raw_request(:post, "/webhook-endpoints/we_1/roll-secret",
params: { ttl_seconds: 3600 }, idempotency_key: "roll-1")
Paths are account-scoped by default. Surfaces that sit outside the account prefix — such as the customer portal — opt out:
client.raw_request(:get, "/billing-portal/token/#{token}/payment-methods",
account_scoped: false)
It returns the parsed JSON body as a Hash; unlike list_* helpers it does not
auto-paginate.
Testing
Point the client at a stub base URL and stub transport (or use WebMock against
the resolved host):
client = PaymentKit::Client.new(
secret_key: "st_test",
base_url: "https://api.test/acc"
)
# Example: inject responses by stubbing the private transport seam in unit tests
allow(client).to receive(:transport).and_return(fake_response)
In RSpec suites, call PaymentKit.reset_configuration! between examples that
mutate global config.
Development
bin/setup
bundle exec rspec
bundle exec rubocop
bundle exec rake # spec + rubocop
Interactive console:
bin/console
Documentation
- API & guides: https://docs.paymentkit.com
- Changelog: CHANGELOG.md
License
The gem is available as open source under the terms of the MIT License.