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. = 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.      = 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:

  1. Explicit base_url if set (trailing slash removed), or
  2. {api_host}/{account_id}

Examples:

# Production-style
PaymentKit.configure do |c|
  c.secret_key = "st_prod_..."
  c. = "acc_prod_abc"
end
# => https://app.paymentkit.com/api/acc_prod_abc

# Custom host
PaymentKit.configure do |c|
  c.secret_key = "st_prod_..."
  c. = "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"
)

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.     = 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 (or Array for auto-paginated lists)
  • Writes: POST, PUT and PATCH send an automatic Idempotency-Key (UUID) unless you pass one
  • Redirects: follows HTTP 307 / 308 (PaymentKit path canonicalization)
  • Retries: 408, 429 and 5xx with exponential backoff; 409 only when PaymentKit marks it retryable
  • Escape hatch: raw_request for 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")

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 status
  • body — raw response body
  • request_id — from the RFC 7807 payload, falling back to the Request-Id header
  • error_code / retryable? — set on transient failures such as invoice_locked
  • problem and error["..."] — 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.message}"
rescue PaymentKit::CardError => e
  warn "Declined: #{e.message}"
rescue PaymentKit::InvalidRequestError => e
  warn "Bad request (#{e.status}): #{e.message} request_id=#{e.request_id}"
rescue PaymentKit::APIConnectionError => e
  warn "Network problem: #{e.message}"
rescue PaymentKit::Error => e
  warn "PaymentKit error: #{e.message}"
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, PUT and PATCH automatically send an Idempotency-Key header (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-first update_subscription_items and apply_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

License

The gem is available as open source under the terms of the MIT License.