0.0
The project is in a healthy, maintained state
PaymentKit is a Ruby SDK for authenticating to and calling the PaymentKit API.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

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:

  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.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 (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")

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