All Docs
FeaturesCalmony PayUpdated March 15, 2026

How a silent Zod strip was eating your trial_end dates

How a silent Zod strip was eating your trial_end dates

Release: v1.0.109
Severity: Bug — data loss (trial period silently ignored)
Affected API: subscriptions.create · CreateSubscriptionParams.trial_end


What happened

The Calmony Pay SDK exposes a subscriptions.create method. According to the pinned SDK Client Interface spec, this method accepts an optional trial_end parameter — a Unix timestamp indicating when a subscription's free-trial period should end.

The TypeScript type was correct:

interface CreateSubscriptionParams {
  // …other fields…
  trial_end?: number; // Unix timestamp
}

And SDK consumers could call it like this:

await calmonyPay.subscriptions.create({
  customer_id: 'cust_abc123',
  plan_id: 'plan_monthly',
  trial_end: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 14, // 14-day trial
});

The call returned no error. The subscription was created. But the trial period was never applied.


Why it was silently lost

Inside subscriptions.create(), named parameters were destructured and the remainder was collected into a rest spread:

// Simplified illustration of the pre-fix behaviour
async create({ customer_id, plan_id, ...rest }) {
  const body = { customer_id, plan_id, ...rest };
  return this.post('/subscriptions', body);
}

trial_end landed inside rest and was forwarded in the request body — so far so good. The problem lived one layer deeper, in the REST API's Zod validation schema:

// Pre-fix schema — trial_end absent
const createSubscriptionSchema = z.object({
  customer_id: z.string(),
  plan_id: z.string(),
  // trial_end was never declared here
});

Zod schemas use strip mode by default: any key not listed in the schema is quietly removed before the data is used. No error, no warning — trial_end was simply thrown away before the record was written to the database.


The fix

trial_end has been added to createSubscriptionSchema:

// Post-fix schema
const createSubscriptionSchema = z.object({
  customer_id: z.string(),
  plan_id: z.string(),
  trial_end: z.number().int().positive().optional(),
});

The field is now validated (must be a positive integer when supplied) and persisted correctly.


What you need to do

Nothing. No SDK interface has changed. Code that was already passing trial_end will start working correctly after upgrading to v1.0.109. No retry or backfill of previously created subscriptions is performed automatically — if you have subscriptions that should have had a trial period applied, you will need to update them manually via the API.


Lessons from this bug

LayerWhat went wrong
Schema definitiontrial_end was omitted from createSubscriptionSchema
Zod default behaviourStrip mode removes unknown keys without raising an error
SDK typingThe TypeScript type was correct, creating a false sense of safety
Test coverageNo integration test asserted that trial_end was persisted

When a field is present in a public SDK interface, it must be explicitly declared at every validation boundary it crosses. TypeScript types alone do not guarantee runtime persistence.