Recurring Payments
Orchestrate billing cycles, recover failed renewals, and monitor subscription lifecycles.
Recurring Payments API
The Recurring Payments API provides the operational infrastructure for managing subscription-based revenue models. Failed renewals enter a three-day recovery lifecycle before cancellation, while expired subscriptions can be explicitly reactivated through the customer lifecycle API.
While a subscription is past_due, an operator can queue one immediate retry with PUT /customers/{customerId}/subscriptions/{subscriptionId}/lifecycle/retry-renewal.json. The manual retry preserves the original renewal boundary and is limited to one idempotent charge per cycle and calendar day.
Payments already collected externally
Use external charge import to record payments already collected by an ERP, billing service, or another payment system. POST /customers/{customerId}/charges/import.json requires charges:write and creates a paid one-shot charge with partner commissions and normal charge events. It preserves the external amounts, taxes, and dates, without calling a gateway, requiring a payment method, creating a subscription, or advancing a subscription renewal date.
Operational Configuration
Audit Recurring Settings
Retrieve the global operational parameters governing recurring billing. This includes the current retry logic for failed payments and the timing offsets for subsequent billing cycles.
recurring_settings:readPath, query, and header parameters
No path, query, or additional header parameters.
Responses
Operational settings successfully retrieved.
{
"status": "success",
"settings": {
"retry_policy": "daily_recovery_window",
"max_attempts": 3,
"next_attempt_offset": "24 hours",
"dunning_notifications": true
}
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/settings.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/settings.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/settings.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Plan provisioning
Plan definitions are company-scoped. Set exactly one positive renewal interval: renewal_days or renewal_months. Use external_id as your stable cross-system key; repeating create with the same external ID and identical normalized payload is an idempotent replay, while different content returns 409.
Validate a plan without saving it
recurring_subscriptions:readPath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"metered": false,
"tags": [
"public"
],
"addons": [],
"limits": {}
}
}subscription.nameRequiredsubscription.priceRequiredsubscription.renewal_dayssubscription.renewal_monthssubscription.trial_dayssubscription.global_discount_typesubscription.global_discount_valuesubscription.tagssubscription.limits / addonsResponses
Validation result and normalized payload; a free plan produces a warning.
{
"valid": true,
"normalized": {
"name": "Growth monthly",
"price": 49,
"renewal_months": 1,
"renewal_days": 0
},
"errors": [],
"warnings": []
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/validate.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"metered": false,
"tags": [
"public"
],
"addons": [],
"limits": {}
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/validate.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"metered": false,
"tags": [
"public"
],
"addons": [],
"limits": {}
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/validate.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"metered": false,
"tags": [
"public"
],
"addons": [],
"limits": {}
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Create a plan
recurring_subscriptions:writePath, query, and header parameters
No path, query, or additional header parameters.
Request Body
{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"description": "Growth plan billed monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"active": true,
"metered": false,
"tags": [
"public"
],
"limits": {},
"addons": []
}
}subscription.nameRequiredsubscription.external_idsubscription.priceRequiredsubscription.renewal_days / renewal_monthsRequiredsubscription.trial_dayssubscription.activesubscription.meteredsubscription.tags / limits / addonsResponses
Plan created.
{
"id": "PLAN_ID",
"external_id": "plan_growth_monthly",
"name": "Growth monthly",
"active": true
}Identical external_id replay.
{
"id": "PLAN_ID",
"external_id": "plan_growth_monthly",
"idempotent_replay": true
}external_id already belongs to different normalized content.
{
"error": "external_id already belongs to a different subscription plan payload"
}Plan validation failed.
{
"error": "Invalid subscription plan"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"description": "Growth plan billed monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"active": true,
"metered": false,
"tags": [
"public"
],
"limits": {},
"addons": []
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"description": "Growth plan billed monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"active": true,
"metered": false,
"tags": [
"public"
],
"limits": {},
"addons": []
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"subscription": {
"name": "Growth monthly",
"external_id": "plan_growth_monthly",
"description": "Growth plan billed monthly",
"price": 49,
"trial_days": 14,
"renewal_days": 0,
"renewal_months": 1,
"active": true,
"metered": false,
"tags": [
"public"
],
"limits": {},
"addons": []
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Retrieve a plan
recurring_subscriptions:readPath, query, and header parameters
subscriptionIdRequiredResponses
Full plan plus the number of attached customer subscriptions.
{
"id": "PLAN_ID",
"name": "Growth monthly",
"price": 49,
"renewal_months": 1,
"active": true,
"affected_customers": 128
}Plan not found in this company.
{
"error": "Subscription plan not found"
}Plan ID format is invalid.
{
"error": "Invalid subscriptionId"
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Update a plan
recurring_subscriptions:writePath, query, and header parameters
subscriptionIdRequiredRequest Body
{
"subscription": {
"description": "Updated plan description",
"price": 59,
"tags": [
"public",
"featured"
]
}
}subscriptionRequiredResponses
Updated full plan.
{
"id": "PLAN_ID",
"name": "Growth monthly",
"price": 59,
"active": true
}Plan not found in this company.
{
"error": "Subscription plan not found"
}Merged plan is invalid.
{
"error": "Invalid subscription plan"
}Request examples
curl --request PUT 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"subscription": {
"description": "Updated plan description",
"price": 59,
"tags": [
"public",
"featured"
]
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json', {
method: 'PUT',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"subscription": {
"description": "Updated plan description",
"price": 59,
"tags": [
"public",
"featured"
]
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"subscription": {
"description": "Updated plan description",
"price": 59,
"tags": [
"public",
"featured"
]
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Deactivate a plan
recurring_subscriptions:writePath, query, and header parameters
subscriptionIdRequiredResponses
Plan disabled for new attachments; existing customer subscriptions are counted, not deleted.
{
"id": "PLAN_ID",
"active": false,
"affected_customers": 128
}Plan not found in this company.
{
"error": "Subscription plan not found"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID/deactivate.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID/deactivate.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions/SUBSCRIPTIONID/deactivate.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.
Subscription Lifecycle Monitoring
Audit Active Subscriptions
Retrieve a comprehensive inventory of all subscriptions currently managed by the recurring payment engine. This endpoint provides crucial visibility into renewal dates, customer associations, and current billing health.
recurring_subscriptions:readPath, query, and header parameters
sizepageResponses
Subscription inventory retrieved.
{
"type": "RecurringSubscriptions",
"count": 1250,
"per_page": 100,
"elements": [
{
"id": "67bdf...",
"customerId": "66a15...",
"subscription_reference": "sub_premium_9921",
"status": "active",
"next_renewal_date": "2026-04-01T00:00:00Z",
"last_payment_status": "success"
}
]
}Request examples
curl --request GET 'https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json' \
--header 'Authorization: Bearer YOUR_API_KEY'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json', {
method: 'GET',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
},
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/recurring-payments/subscriptions.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
],
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.