DonutworkHelp Center
Api ReferenceRevenue & Partners
Donutwork documentation

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.

GET
/2026-02-01/recurring-payments/settings.json
Required permissionrecurring_settings:read

Path, 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

POST
/2026-02-01/recurring-payments/subscriptions/validate.json
Required permissionrecurring_subscriptions:read

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "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.namestringRequired
Non-empty plan name.
subscription.pricenumberRequired
Non-negative recurring base price.
subscription.renewal_daysinteger
Positive day interval; mutually exclusive with renewal_months.
subscription.renewal_monthsinteger
Positive month interval; mutually exclusive with renewal_days.
subscription.trial_daysinteger
Non-negative trial length.
subscription.global_discount_typeenum
fixed or percentage.
subscription.global_discount_valuenumber
Non-negative value; percentages cannot exceed 100.
subscription.tagsstring[]
Array or comma-separated string.
subscription.limits / addonsobject|array
Plan limits and add-on definitions.

Responses

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

POST
/2026-02-01/recurring-payments/subscriptions.json
Required permissionrecurring_subscriptions:write

Path, query, and header parameters

No path, query, or additional header parameters.

Request Body

JSON
{
  "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.namestringRequired
Non-empty plan name.
subscription.external_idstring
Stable idempotency and reconciliation key from your system.
subscription.pricenumberRequired
Non-negative recurring base price.
subscription.renewal_days / renewal_monthsintegerRequired
Set exactly one interval to a positive value.
subscription.trial_daysinteger
Non-negative trial length.
subscription.activeboolean
Whether new customer subscriptions can use the plan. Default: true.
subscription.meteredboolean
Whether usage reporting applies. Default: false.
subscription.tags / limits / addonsarray|object
Classification and entitlement configuration.

Responses

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

GET
/2026-02-01/recurring-payments/subscriptions/{subscriptionId}.json
Required permissionrecurring_subscriptions:read

Path, query, and header parameters

subscriptionIdstring· pathRequired
24-character company-scoped plan ID.

Responses

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

PUT
/2026-02-01/recurring-payments/subscriptions/{subscriptionId}.json
Required permissionrecurring_subscriptions:write

Path, query, and header parameters

subscriptionIdstring· pathRequired
24-character company-scoped plan ID.

Request Body

JSON
{
  "subscription": {
    "description": "Updated plan description",
    "price": 59,
    "tags": [
      "public",
      "featured"
    ]
  }
}
subscriptionobjectRequired
Partial plan update. Omitted fields retain their current normalized values; the final plan must remain valid.

Responses

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

POST
/2026-02-01/recurring-payments/subscriptions/{subscriptionId}/deactivate.json
Required permissionrecurring_subscriptions:write

Path, query, and header parameters

subscriptionIdstring· pathRequired
24-character company-scoped plan ID.

Responses

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.

GET
/2026-02-01/recurring-payments/subscriptions.json
Required permissionrecurring_subscriptions:read

Path, query, and header parameters

sizeinteger· query
Maximum records per page.
pageinteger· query
Target page index.

Responses

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.

On this page