feat: add manual subscription date editing for admins (#7257)
What changed, and why it matters
This commit adds a new admin-only feature that lets store administrators manually edit subscription start and expiration dates for subscribers, both through the web interface and the API. It is a normal feature addition, not a vulnerability fix. The code includes basic validation (expiration must be after start) and is restricted to users who already have permission to manage subscribers. There is no direct evidence in the commit that this introduces a security bug, but any date-editing feature could theoretically be misused by a compromised admin account or through authorization bugs.
No immediate action required; this is a feature commit. As a defensive review note, operators should ensure that API keys with `btcpay.store.canmanagesubscribers` scope are issued only to trusted parties, and audit logs of subscription date changes should be reviewed if available. If a security review is desired, focus on whether the `CanManageSubscribers` policy can be bypassed or overly granted.
Security signals we found
New privileged admin/API capability added (manual date override)
Authorization enforced by existing subscription management policy
Server-side date validation present (expiration after start)
Timezone offset bounds checked on UI controller (-840 to +840 minutes)
No input sanitization concerns beyond date/offset validation visible
No vendor disclosure of security relevance
Evidence from the diff
The change introduces a new UpdateSubscriberDates endpoint in the Greenfield API and a matching UI modal for editing subscription dates. It adds request models, client SDK methods, service logic, tests, and Swagger documentation. Authorization is enforced via SubscriptionsPolicies.CanManageSubscribers. Server-side validation ensures expirationDate > startDate and clamps timezone offsets to ±840 minutes. The service updates PlanStarted, PeriodEnd, TrialEnd, GracePeriodEnd, and ReminderDate. No security relevance is claimed by the vendor, and no external advisory or CVE is referenced.
Changed components
BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.csBTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.csBTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.csBTCPayServer.Client/BTCPayServerClient.Subscriptions.csBTCPayServer.Client/Models/Subscriptions/UpdateSubscriberDatesRequest.csBTCPayServer/Plugins/Subscriptions/Views/UIOffering/EditDatesModal.cshtmlBTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtmlBTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.jsonBTCPayServer.Tests/SubscriptionTests.csInspect captured patch +370 / −3
diff --git a/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs b/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs
index f91424c..1333777 100644
--- a/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs
+++ b/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs
@@ -54,6 +54,10 @@ public partial class BTCPayServerClient
public async Task<SubscriberModel> UnsuspendSubscriber(string storeId, string offeringId, string customerSelector, CancellationToken token = default)
=> await SendHttpRequest<SubscriberModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/unsuspend", null, HttpMethod.Post, token);
+ public async Task<SubscriberModel> UpdateSubscriberDates(string storeId, string offeringId, string customerSelector,
+ UpdateSubscriberDatesRequest request, CancellationToken token = default)
+ => await SendHttpRequest<SubscriberModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{Uri.EscapeDataString(customerSelector)}/dates", request, HttpMethod.Put, token);
+
public async Task<PortalSessionModel> CreatePortalSession(CreatePortalSessionRequest request, CancellationToken token = default)
=> await SendHttpRequest<PortalSessionModel>($"api/v1/subscriber-portal", request, HttpMethod.Post, token);
public async Task<PortalSessionModel> GetPortalSession(string portalSessionId, CancellationToken token = default)
diff --git a/BTCPayServer.Client/Models/Subscriptions/UpdateSubscriberDatesRequest.cs b/BTCPayServer.Client/Models/Subscriptions/UpdateSubscriberDatesRequest.cs
new file mode 100644
index 0000000..4f0fbbf
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/UpdateSubscriberDatesRequest.cs
@@ -0,0 +1,13 @@
+using System;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Client.Models;
+
+public class UpdateSubscriberDatesRequest
+{
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? StartDate { get; set; }
+
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? ExpirationDate { get; set; }
+}
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
index b78752a..aebc136 100644
--- a/BTCPayServer.Tests/SubscriptionTests.cs
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -486,6 +486,24 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
Assert.False(subscriber.IsSuspended);
Assert.Null(subscriber.SuspensionReason);
+ var newExpiration = DateTimeOffset.UtcNow.AddDays(90);
+ subscriber = await client.UpdateSubscriberDates(user.StoreId, offering.Id,
+ planCheckout.Subscriber.Customer.Id,
+ new UpdateSubscriberDatesRequest { ExpirationDate = newExpiration });
+ Assert.Equal(newExpiration.ToUnixTimeSeconds(), subscriber.PeriodEnd!.Value.ToUnixTimeSeconds());
+ Assert.True(subscriber.IsActive);
+
+ await AssertEx.AssertApiError(400, "invalid-dates", () => client.UpdateSubscriberDates(
+ user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id,
+ new UpdateSubscriberDatesRequest
+ {
+ StartDate = DateTimeOffset.UtcNow.AddDays(10),
+ ExpirationDate = DateTimeOffset.UtcNow.AddDays(5)
+ }));
+ var noOpResult = await client.UpdateSubscriberDates(user.StoreId, offering.Id,
+ planCheckout.Subscriber.Customer.Id, new UpdateSubscriberDatesRequest());
+ Assert.Equal(newExpiration.ToUnixTimeSeconds(), noOpResult.PeriodEnd!.Value.ToUnixTimeSeconds());
+
var session = await client.CreatePortalSession(new()
{
StoreId = user.StoreId,
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs
index 4de16b4..37f966a 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs
@@ -255,6 +255,27 @@ namespace BTCPayServer.Plugins.Subscriptions.Controllers
return await GetSubscriber(storeId, offeringId, customerSelector);
}
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = SubscriptionsPolicies.CanManageSubscribers)]
+ [HttpPut("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/dates")]
+ public async Task<IActionResult> UpdateSubscriberDates(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector,
+ [FromBody] UpdateSubscriberDatesRequest? request)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ if (request is null or { StartDate: null, ExpirationDate: null })
+ return await GetSubscriber(storeId, offeringId, customerSelector);
+ var startDate = (request.StartDate ?? subscriber.PlanStarted).ToUniversalTime();
+ var expirationDate = request.ExpirationDate?.ToUniversalTime();
+ if (expirationDate is { } exp && exp <= startDate)
+ return this.CreateAPIError(400, "invalid-dates", "Expiration date must be after the start date.");
+ await subscriptionHostedService.UpdateDates(subscriber.Id, startDate, expirationDate);
+ ctx.ChangeTracker.Clear();
+ return await GetSubscriber(storeId, offeringId, customerSelector);
+ }
+
[AllowAnonymous]
[HttpPost("~/api/v1/plan-checkout/{checkoutId}")]
public async Task<IActionResult> ProceedPlanCheckout(string checkoutId, [FromQuery] string? email = null)
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
index ade74cd..6c7a22b 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -100,7 +100,9 @@ public partial class UIOfferingController(
[HttpPost("stores/{storeId}/offerings/{offeringId}/Subscribers")]
public async Task<IActionResult> SubscriberSuspend(string storeId, string offeringId, string customerId, string? command = null,
- string? suspensionReason = null, decimal? amount = null, string? description = null)
+ string? suspensionReason = null, decimal? amount = null, string? description = null,
+ DateOnly? startDate = null, DateOnly? expirationDate = null,
+ int? timezoneOffset = null, int? expirationTimezoneOffset = null)
{
await using var ctx = DbContextFactory.CreateContext();
var sub = await ctx.Subscribers.GetByCustomerId(customerId, offeringId: offeringId, storeId: storeId);
@@ -156,6 +158,53 @@ public partial class UIOfferingController(
});
TempData.SetStatusSuccess(message);
}
+ else if (command is "edit-dates")
+ {
+ if (startDate is null)
+ {
+ TempData.SetStatusMessageModel(new()
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Html = StringLocalizer["Invalid start date provided."]
+ });
+ return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
+ }
+
+ var startOffsetMinutes = timezoneOffset ?? 0;
+ var expOffsetMinutes = expirationTimezoneOffset ?? startOffsetMinutes;
+ if (startOffsetMinutes < -840 || startOffsetMinutes > 840 ||
+ expOffsetMinutes < -840 || expOffsetMinutes > 840)
+ {
+ TempData.SetStatusMessageModel(new()
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Html = StringLocalizer["Invalid timezone offset."]
+ });
+ return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
+ }
+
+ var parsedStart = new DateTimeOffset(startDate.Value.ToDateTime(TimeOnly.MinValue),
+ TimeSpan.FromMinutes(-startOffsetMinutes)).ToUniversalTime();
+
+ DateTimeOffset? parsedExpiration = null;
+ if (expirationDate is not null)
+ {
+ parsedExpiration = new DateTimeOffset(expirationDate.Value.ToDateTime(TimeOnly.MinValue),
+ TimeSpan.FromMinutes(-expOffsetMinutes)).ToUniversalTime();
+ if (parsedExpiration.Value <= parsedStart)
+ {
+ TempData.SetStatusMessageModel(new()
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Html = StringLocalizer["Expiration date must be after the start date."]
+ });
+ return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
+ }
+ }
+
+ await SubsService.UpdateDates(sub.Id, parsedStart, parsedExpiration);
+ TempData.SetStatusSuccess(StringLocalizer["Subscription dates updated for {0}", subName]);
+ }
return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
}
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
index 4424912..2c009c3 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
@@ -134,8 +134,42 @@ public class SubscriptionHostedService(
await ctx.SaveChangesAsync();
await UpdateSubscriptionStates(subCtx, move.MemberSelector);
}
+ else if (evt is UpdateDatesRequest datesRequest)
+ {
+ var ctx = subCtx.Context;
+ var sub = await ctx.Subscribers.IncludeAll().FirstOrDefaultAsync(s => s.Id == datesRequest.SubId, cancellationToken);
+ if (sub is null)
+ throw new InvalidOperationException("Subscriber not found");
+
+ sub.PlanStarted = datesRequest.StartDate;
+ sub.PaymentReminded = false;
+
+ if (datesRequest.ExpirationDate is { } expDate)
+ {
+ if (sub.TrialEnd is not null)
+ {
+ sub.TrialEnd = expDate;
+ sub.ReminderDate = expDate - TimeSpan.FromDays(sub.PaymentReminderDaysOrDefault);
+ }
+ else if (sub.Plan.RecurringType != PlanData.RecurringInterval.Lifetime)
+ {
+ sub.PeriodEnd = expDate;
+ sub.TrialEnd = null;
+ sub.GracePeriodEnd = sub.Plan.GracePeriodDays > 0 ? expDate.AddDays(sub.Plan.GracePeriodDays) : (DateTimeOffset?)null;
+ sub.ReminderDate = expDate - TimeSpan.FromDays(sub.PaymentReminderDaysOrDefault);
+ }
+ }
+
+ await ctx.SaveChangesAsync(cancellationToken);
+ await UpdateSubscriptionStates(subCtx, datesRequest.SubId);
+ }
}
+ record UpdateDatesRequest(long SubId, DateTimeOffset StartDate, DateTimeOffset? ExpirationDate);
+
+ public Task UpdateDates(long subId, DateTimeOffset startDate, DateTimeOffset? expirationDate)
+ => RunEvent(new UpdateDatesRequest(subId, startDate, expirationDate));
+
SubscriptionContext CreateContext() => CreateContext(CancellationToken);
SubscriptionContext CreateContext(CancellationToken cancellationToken) =>
diff --git a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/EditDatesModal.cshtml b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/EditDatesModal.cshtml
new file mode 100644
index 0000000..1e86d75
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/EditDatesModal.cshtml
@@ -0,0 +1,109 @@
+<div class="modal fade" id="editDatesModal" tabindex="-1" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h4 class="modal-title" text-translate="true">Edit Subscription Dates</h4>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <form method="post">
+ <div class="modal-body">
+ <div>
+ <span text-translate="true">Editing dates for</span>
+ <span class="edit-dates-subscriber-name fw-semibold"></span>
+ </div>
+ </div>
+ <div class="modal-body pt-0">
+ <input name="customerId" type="hidden" />
+ <input name="timezoneOffset" type="hidden" class="edit-dates-tz" />
+ <input name="expirationTimezoneOffset" type="hidden" class="edit-dates-expiry-tz" />
+
+ <div class="mb-3">
+ <label class="form-label" text-translate="true">Start Date</label>
+ <input type="date" name="startDate" class="form-control edit-dates-start" required />
+ </div>
+
+ <div class="mb-3 edit-dates-expiration-group">
+ <label class="form-label edit-dates-expiration-label"></label>
+ <input type="date" name="expirationDate" class="form-control edit-dates-expiration" />
+ <div class="form-text text-muted" text-translate="true">
+ Grace period and reminder date are recalculated automatically from plan settings.
+ Leave blank for lifetime plans.
+ </div>
+ </div>
+ <div class="text-danger edit-dates-error" style="display:none"></div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" text-translate="true">Cancel</button>
+ <button type="submit" class="btn btn-primary edit-dates-save" name="command" value="edit-dates" text-translate="true">Save</button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ const modalEl = document.getElementById('editDatesModal');
+ if (!modalEl) return;
+
+ const startInput = modalEl.querySelector('.edit-dates-start');
+ const expirationInput = modalEl.querySelector('.edit-dates-expiration');
+ const expirationLabel = modalEl.querySelector('.edit-dates-expiration-label');
+ const errorEl = modalEl.querySelector('.edit-dates-error');
+ const saveBtn = modalEl.querySelector('.edit-dates-save');
+ const tzInput = modalEl.querySelector('.edit-dates-tz');
+ const expiryTzInput = modalEl.querySelector('.edit-dates-expiry-tz');
+
+ function offsetForDate(dateString) {
+ return dateString
+ ? new Date(dateString + 'T00:00:00').getTimezoneOffset()
+ : new Date().getTimezoneOffset();
+ }
+
+ function validate() {
+ const start = startInput.value;
+ const expiration = expirationInput.value;
+ if (start && expiration && expiration <= start) {
+ errorEl.innerText = 'Expiration date must be after the start date.';
+ errorEl.style.display = '';
+ saveBtn.disabled = true;
+ } else {
+ errorEl.style.display = 'none';
+ saveBtn.disabled = false;
+ }
+ }
+
+ startInput.addEventListener('change', function () {
+ tzInput.value = offsetForDate(this.value);
+ validate();
+ });
+ expirationInput.addEventListener('change', function () {
+ expiryTzInput.value = offsetForDate(this.value);
+ validate();
+ });
+
+ modalEl.addEventListener('show.bs.modal', function (event) {
+ const trigger = event.relatedTarget;
+ if (!trigger) return;
+
+ const tr = trigger.closest('tr');
+ modalEl.querySelector('input[name="customerId"]').value = tr.getAttribute('data-subscriber-id') || '';
+ modalEl.querySelector('.edit-dates-subscriber-name').innerText = tr.getAttribute('data-subscriber-email') || '';
+
+ const startVal = tr.getAttribute('data-plan-started') || '';
+ const expVal = tr.getAttribute('data-expiration') || '';
+ startInput.value = startVal;
+ expirationInput.value = expVal;
+ tzInput.value = offsetForDate(startVal);
+ expiryTzInput.value = offsetForDate(expVal);
+
+ const isTrial = tr.getAttribute('data-is-trial') === 'true';
+ expirationLabel.innerText = isTrial ? 'Trial End Date' : 'Expiration Date';
+
+ errorEl.style.display = 'none';
+ saveBtn.disabled = false;
+ });
+ });
+</script>
diff --git a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtml b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtml
index 67a9c69..502238c 100644
--- a/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtml
+++ b/BTCPayServer/Plugins/Subscriptions/Views/UIOffering/Offering.cshtml
@@ -1,4 +1,4 @@
-@using BTCPayServer.Client
+@using BTCPayServer.Client
@using BTCPayServer.Plugins.Emails
@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Plugins.Subscriptions.Controllers
@@ -203,6 +203,8 @@
<tr>
<th text-translate="true">User</th>
<th text-translate="true">Credits</th>
+ <th text-translate="true">Start Date</th>
+ <th text-translate="true">Expiration</th>
<th text-translate="true">Plan</th>
<th text-translate="true">Phase</th>
<th text-translate="true">Status</th>
@@ -217,7 +219,10 @@
<tr data-subscriber-email="@subscriber.Data.Customer.Email.Get()"
data-subscriber-id="@subscriber.Data.CustomerId"
data-currency="@subscriber.Data.Plan.Currency"
- data-current-credit-value="@subscriber.Data.GetCredit()">
+ data-current-credit-value="@subscriber.Data.GetCredit()"
+ data-plan-started="@subscriber.Data.PlanStarted.UtcDateTime.ToString("yyyy-MM-dd")"
+ data-expiration="@((subscriber.Data.TrialEnd ?? subscriber.Data.PeriodEnd)?.UtcDateTime.ToString("yyyy-MM-dd") ?? "")"
+ data-is-trial="@(subscriber.Data.TrialEnd.HasValue ? "true" : "false")">
<td class="fw-semibold text-nowrap d-flex align-items-center subscriber-email-col">
@if (subscriber.Data.TestAccount)
{
@@ -273,6 +278,36 @@
@DisplayFormatter.Currency(subscriber.Data.GetCredit(), subscriber.Data.Plan.Currency, DisplayFormatter.CurrencyFormat.CodeAndSymbol)
</span>
</td>
+ <td class="text-nowrap">
+ <a href="#"
+ class="edit-dates-link text-decoration-none"
+ data-bs-toggle="modal"
+ data-bs-target="#editDatesModal"
+ title="@StringLocalizer["Edit dates"]">
+ @subscriber.Data.PlanStarted.ToString("d")
+ <span class="ms-1 text-muted small">✎</span>
+ </a>
+ </td>
+ <td class="text-nowrap">
+ @{
+ var subExpiry = subscriber.Data.TrialEnd ?? subscriber.Data.PeriodEnd;
+ }
+ @if (subExpiry.HasValue)
+ {
+ <a href="#"
+ class="edit-dates-link text-decoration-none"
+ data-bs-toggle="modal"
+ data-bs-target="#editDatesModal"
+ title="@StringLocalizer["Edit dates"]">
+ @subExpiry.Value.ToString("d")
+ <span class="ms-1 text-muted small">✎</span>
+ </a>
+ }
+ else
+ {
+ <span class="text-muted">—</span>
+ }
+ </td>
<td class="text-nowrap">@subscriber.Data.Plan.Name</td>
<td>
<span class="subscriber-phase">
@@ -454,6 +489,7 @@
<partial name="SuspendSubscriberModal" />
<partial name="NewSubscriberModal" model="Model.SelectablePlans" />
<partial name="ChangeCreditModal" />
+ <partial name="EditDatesModal" />
</div>
@section PageFootContent {
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json
index 427cb41..c69491a 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json
@@ -481,6 +481,69 @@
}
}
},
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/dates": {
+ "put": {
+ "summary": "Update subscriber dates",
+ "description": "Manually overrides the subscription start date and/or expiration date for a subscriber.\n\nOmitting a field leaves the corresponding date unchanged. An empty body `{}` is a no-op and returns the subscriber unchanged.\n\nWhen `expirationDate` is provided, the grace period end and reminder date are recalculated automatically from the plan settings.",
+ "operationId": "UpdateSubscriberDates",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Dates to update. All fields are optional — omitting a field leaves it unchanged.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSubscriberDatesRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Subscriber dates updated (or unchanged if no fields were provided).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriberModel"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Error code: `invalid-dates`. The expiration date must be after the start date.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Subscriber not found."
+ }
+ }
+ }
+ },
"/api/v1/plan-checkout/{checkoutId}": {
"get": {
"summary": "Get a plan checkout",
@@ -920,6 +983,26 @@
}
}
},
+ "UpdateSubscriberDatesRequest": {
+ "type": "object",
+ "description": "Request payload for updating subscriber dates. All fields are optional — omitting a field leaves the corresponding date unchanged.",
+ "properties": {
+ "startDate": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "New subscription start date as a Unix timestamp. Omit to leave the current start date unchanged.",
+ "example": 1710000000
+ },
+ "expirationDate": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "New expiration date as a Unix timestamp. Updates `trialEnd` if the subscriber is in a trial, otherwise updates `periodEnd`. Grace period end and reminder date are recalculated automatically. Omit to leave the current expiration date unchanged.",
+ "example": 1712678400
+ }
+ }
+ },
"CustomerSelector": {
"type": "string",
"description": "Flexible identifier for selecting a customer. Supports: customer ID (e.g., `cust_abc123`), an email (e.g., `user@example.com`), or a key/value identity (e.g., `Email:user@example.com`).",
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.