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
| Layer | What went wrong |
|---|---|
| Schema definition | trial_end was omitted from createSubscriptionSchema |
| Zod default behaviour | Strip mode removes unknown keys without raising an error |
| SDK typing | The TypeScript type was correct, creating a false sense of safety |
| Test coverage | No 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.