Agentic Subscription Management: Trials, Upgrades, and Downgrades via Tool Calls
Design a safe tool surface for agent-driven subscription changes: proration, trials, dunning, idempotency keys, confirmation gates, and reconciliation.
Published on • October 16, 2026
AI Assistant

Every subscription mutation is a money movement with a customer standing behind it, which makes billing a poor place for a generous, improvisational agent: a loose argument order double-charges someone, a missing idempotency key turns a flaky network into a duplicate upgrade, and an unpriced downgrade surfaces as an angry ticket three weeks later. Stripe’s API is explicit about exactly the ambiguities you fear, so you can build a tool surface where each dangerous edge is typed away, gated behind a confirmation, or reconciled after the fact.
Keep the tool surface small and boring
An agent does not need the whole subscriptions API. It needs six verbs, each with a narrow schema:
from typing import Literal
from pydantic import BaseModel, Field
class StartTrial(BaseModel):
subscription_id: str = Field(pattern=r'^sub_[A-Za-z0-9]+$')
trial_days: int = Field(ge=1, le=90)
class ChangePlan(BaseModel):
subscription_id: str = Field(pattern=r'^sub_[A-Za-z0-9]+$')
new_price_id: str = Field(pattern=r'^price_[A-Za-z0-9]+$')
proration_behavior: Literal['always_invoice', 'create_prorations', 'none'] = 'create_prorations'
# The agent must state the billing consequence it believes will happen.
expected_effect: Literal[
'credit_on_next_invoice', 'charge_now', 'no_proration'
]
class EndTrial(BaseModel):
subscription_id: str = Field(pattern=r'^sub_[A-Za-z0-9]+$')
convert_now: bool
class CancelAtPeriodEnd(BaseModel):
subscription_id: str = Field(pattern=r'^sub_[A-Za-z0-9]+$')
feedback: Literal[
'too_expensive', 'missing_features', 'switching_provider',
'bugs', 'temporary', 'other',
] = 'other'
class PauseBilling(BaseModel):
subscription_id: str = Field(pattern=r'^sub_[A-Za-z0-9]+$')
mode: Literal['pause_collection', 'pause_status']
class UpdatePaymentMethod(BaseModel):
customer_id: str = Field(pattern=r'^cus_[A-Za-z0-9]+$')
payment_method_id: str = Field(pattern=r'^pm_[A-Za-z0-9]+$')
set_as_default: bool = True
Three details there do more than they look like they do: the ID patterns stop the model from inventing identifiers, the proration_behavior literal enumerates only what the API accepts, and expected_effect forces the agent to say out loud whether the customer gets a credit, an immediate charge, or nothing. You can check that belief against the API response instead of trusting a confident paragraph.
In Pydantic AI, the tool function signature and docstring become the schema sent to the model, arguments are validated before your code runs, and validation errors go back to the model to retry. You are never parsing free text into a POST /v1/subscriptions call.
Trials: start, convert, end early
Trials are one POST /v1/subscriptions away. On creation you can pass trial_period_days, or trial_end as a timestamp or the special value now to end a trial immediately; trial_end overrides the plan’s default trial, and trial_from_plan (default false) is mutually exclusive with it. In Stripe’s example response, trial_settings.end_behavior reads missing_payment_method: create_invoice.
Give the agent two tools, start_trial and end_trial, and make conversion a no-op path rather than a new subscription: ending a trial early is trial_end=now on an update, not a cancel-and-recreate. Stripe fires customer.subscription.trial_will_end three days before the scheduled end, or immediately when a trial ends early; its docs warn that when a trial is shortened to fewer than three days, the event can fire immediately, even during the transaction that collects payment, so check subscription status and the latest invoice before sending payment reminders. An agent that nags a customer who was already charged is a support ticket subject line you never want to read.
Upgrades and downgrades: proration is the whole decision
Stripe’s update endpoint documents the default plainly: subscriptions are prorated by default, and switching prices does not normally change the billing date or generate an immediate charge. The docs’ own example is the clearest spec you will get: a customer on a 100 USD price from May 1 who switches to a 200 USD price on May 15 is billed 250 USD on June 1 (200 renewal plus a 50 USD prorated adjustment), and a downgrade generates a credit applied to the next invoice.
Three situations break that calm and charge immediately: changing the billing interval (monthly to yearly), moving from free to paid, and a trial starting or ending. In those cases Stripe credits the unused time, charges the new price, and resets the billing date.
So proration_behavior is a product decision you encode, not a detail:
curl https://api.stripe.com/v1/subscriptions/sub_1MowQVLkdIwHu7ixeRlqHVzs \
-u "$STRIPE_SECRET_KEY:" \
-H "Idempotency-Key: upgrade:cus_42:2026-10-16:pro" \
-d "items[0][id]=si_Na6dzxczY5fwHx" \
-d "items[0][price]=price_2000" \
-d proration_behavior=always_invoice \
-d payment_behavior=pending_if_incomplete \
-d "metadata[changed_by]=agent" \
-d "metadata[request_id]=req_8f31"
create_prorations(the default) creates proration items that are only invoiced immediately under Stripe’s immediate-payment conditions.always_invoiceinvoices the prorations right away and attempts to collect. This is your “charge now” button, so it deserves a confirmation gate.nonedisables prorations; the customer keeps the old charge until renewal. Note that switching billing intervals withnonestill resets the billing date and bills immediately, with no credit.
Before any irreversible quote, preview with the invoice create-preview endpoint and show the delta to a human. Stripe also accepts proration_date on updates so you can apply exactly the prorations you previewed, which is how you prorate by day instead of by second.
Pause, cancel, and cancel-at-period-end
The endpoint list itself is your menu: DELETE /v1/subscriptions/:id cancels, POST /v1/subscriptions/:id/pause and /resume manage pause, and updates handle the rest. Two distinctions will save you from writing wrong tool descriptions:
cancel_at_period_endon an update flags cancellation for the end of the current period. It is the friendly cancel, and it is reversible.pause_collectionon an update pauses payment collection, but Stripe’s docs note the subscription status is unchanged and is not updated topaused. A real status change topausedproduces thecustomer.subscription.pausedevent, which Stripe notes applies only when subscriptions enterstatus=paused, not when payment collection is paused.
Model both, and wrap every mutation in one helper that sends the key as a header:
def stripe_call(method: str, path: str, params: dict, idempotency_key: str) -> dict:
"""One place that always sends the key as a header, whatever SDK you wrap."""
return stripe_request(method, path, params=params,
headers={'Idempotency-Key': idempotency_key})
# mode='pause_collection' keeps status active; mode='pause_status' sets status=paused.
if mode == 'pause_collection':
result = stripe_call('POST', f'/v1/subscriptions/{sub_id}',
{'pause_collection[behavior]': 'keep_as_draft_invoice'},
key_for('pause_collection', args))
else:
result = stripe_call('POST', f'/v1/subscriptions/{sub_id}/pause', {},
key_for('pause_status', args))
Cancel-at-period-end belongs behind a confirmation gate: immediate cancellation is the action customers complain about most.
Idempotency keys on every mutation
Stripe’s idempotency contract is the difference between a retried request and a duplicated charge. Provide an Idempotency-Key header, and if a connection error occurs you can safely repeat the request without creating a second object or applying the update twice. For API v1, Stripe saves the status code and body of the first request once endpoint execution begins, and retries with the same key return the saved response, including 500 errors. Use V4 UUIDs or another random string with enough entropy, up to 255 characters, never containing sensitive data such as email addresses. Keys can be pruned after 24 hours, after which reuse generates a new request, and if incoming parameters differ from the original, the API errors to prevent misuse. Validation failures and concurrent conflicts save nothing, so those are retryable.
The rule for your tools: generate the key from the intent, not from a random source, so a retry of the same intent is a replay:
import hashlib
def key_for(action: str, args: BaseModel, attempt: str | None = None) -> str:
"""Stable per intent. A regenerated key on retry would defeat the purpose."""
payload = f'{action}:{args.model_dump_json()}'
digest = hashlib.sha256(payload.encode()).hexdigest()[:40]
suffix = f':{attempt}' if attempt else ''
return f'agent:{digest}{suffix}'[:255] # never put emails or names in here
Stripe accepts keys on all POST requests; GET and DELETE are already idempotent, so a key has no effect there.
Payment methods, dunning, and recovery
Give the agent an update_payment_method tool, not raw card entry. default_payment_method on create and update must belong to the subscription’s customer and takes precedence over default_source; if neither is set, invoices fall back to the customer’s invoice_settings.default_payment_method or default_source. Let Stripe collect card details through Checkout or the Customer Portal, and let your tool only attach and set defaults.
Then make failure handling explicit with payment_behavior:
allow_incomplete(default since 2019-03-14): on creation a failed payment still creates the subscription withstatus=incomplete; on update, failure moves it tostatus=past_due, Stripe retries if you have retries configured, and a customer action requirement arrives as aninvoice.payment_action_requiredwebhook.default_incomplete: no payment attempt; you must confirm the invoice PaymentIntent, and the invoice hasauto_advance=falseso Stripe will not retry or finalize it.error_if_incomplete: HTTP 402, operation not performed. It cannot handle 3DS flows because it errors instead of producing a PaymentIntent inrequires_action.pending_if_incomplete(updates only): on payment failure Stripe creates a pending update that applies only if payment eventually succeeds.
For a billing agent, pending_if_incomplete is usually the right default on paid upgrades: the change either lands or the subscription is untouched.
Confirmation gates before irreversible actions
Gate three things: always_invoice upgrades (money moves now), trial_end=now (converts a free user), and cancellation or immediate downgrades. Pydantic AI owns the approval round trip: a tool guard returning GuardrailResult.approve() holds the call back, the run finishes with deferred tool requests, and you resume it with the human’s answer.
from pydantic_ai_harness import GuardrailResult, ToolGuardrail
from pydantic_ai_harness.guardrails import ToolCallInfo
def confirm_money_moving(call: ToolCallInfo) -> GuardrailResult:
money_now = call.name in {'cancel_subscription', 'end_trial'} or (
call.name == 'change_plan'
and call.args.get('proration_behavior') == 'always_invoice'
)
return GuardrailResult.approve() if money_now else GuardrailResult.allow()
agent = Agent('openai:gpt-6-sol', capabilities=[ToolGuardrail(guard=confirm_money_moving)])
A denial reaches the model as the tool result, so it can explain itself.
Reconciliation when the API returns an uncertain state
Timeouts are not failures until proven otherwise. Classify every outcome into three buckets and act accordingly:
def apply_change(args: ChangePlan) -> Outcome:
try:
sub = stripe_call(
'POST', f'/v1/subscriptions/{args.subscription_id}',
{
'items[0][id]': SI_ID,
'items[0][price]': args.new_price_id,
'proration_behavior': args.proration_behavior,
'payment_behavior': 'pending_if_incomplete',
},
idempotency_key=key_for('change_plan', args),
)
except RateLimitError: # map these to your SDK's exception types
return Outcome.uncertain(reason='rate_limited', retry_same_key=True)
except ConnectionError:
# The request may have landed. Never generate a new key here.
return Outcome.uncertain(reason='connection_error', retry_same_key=True)
except CardDeclined as e:
return Outcome.failed(reason=str(e), guidance='update_payment_method')
else:
return reconcile(sub, args)
def reconcile(sub, args: ChangePlan) -> Outcome:
item = sub['items']['data'][0]
if item['price']['id'] != args.new_price_id:
return Outcome.uncertain(reason='response_not_applied', action='retrieve')
if sub.get('pending_update'):
return Outcome.pending(payment='awaiting_invoice_payment') # pending_if_incomplete
if sub['status'] in ('incomplete', 'past_due'):
return Outcome.payment_failed(status=sub['status'], latest_invoice=sub['latest_invoice'])
return Outcome.applied(status=sub['status'], next_invoice=sub['latest_invoice'])
On any uncertain, re-read with GET /v1/subscriptions/:id and compare against the requested state; on a webhook, treat customer.subscription.updated, pending_update_applied, and pending_update_expired as the source of truth. pending_update_expired is terminal: the update expired before the invoice was paid, so any optimistic “upgraded” message must be retractable.
Finally, stamp every mutation with metadata (changed_by=agent, request_id, conversation id). That is what makes a month-end dispute answerable in minutes instead of an afternoon.
Key Takeaways
- Expose six narrow tools with strict schemas; require the agent to declare the expected billing effect and check it against the response.
- Proration defaults to
create_prorations;always_invoicecharges now and needs a confirmation gate;noneskips credits but still resets the billing date on interval changes. - Distinguish
cancel_at_period_endfrompause_collection, and note that pausing collection does not change subscription status. - Put a deterministic
Idempotency-Keyderived from the intent on everyPOST, and never regenerate it after a timeout. - Prefer
payment_behavior=pending_if_incompletefor paid upgrades so a failed charge leaves the subscription untouched. - Reconcile uncertain outcomes by re-reading state and letting webhook events (
pending_update_applied,pending_update_expired) settle the truth.