Skip to content

Payment Plans API

The Payment Plans API lets you programmatically create, manage, and control recurring billing schedules. A payment plan generates and processes payment intents automatically on your chosen frequency.

For background on how plans work, see Understanding Plans.

All payment plan operations require a secret key (sk_xxx) with payment_plans:read or payment_plans:write scope.

Terminal window
curl -X POST https://api.elasticpay.co/api/v1/payment_plans \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "cus_0abc123def456ghi789jkl012mno",
"name": "Monthly gym membership",
"frequency_type": "monthly",
"recurring_amount_cents": 4900,
"currency": "AUD",
"start_date": "2026-05-01",
"until_further_notice": true
}'

Response

{
"id": "pp_0abc123def456ghi789jkl012mno",
"customer_id": "cus_0abc123def456ghi789jkl012mno",
"name": "Monthly gym membership",
"state": "active",
"frequency_type": "monthly",
"frequency_period": 1,
"recurring_amount_cents": 4900,
"currency": "AUD",
"start_date": "2026-05-01",
"until_further_notice": true,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:00Z"
}
Parameter Type Required Description
customer_id string Yes Customer (cus_xxx) who will be charged
name string No Human-readable label, up to 50 characters
frequency_type string Yes weekly or monthly
frequency_period integer No Interval multiplier (default 1). 2 + weekly = fortnightly
recurring_amount_cents integer Yes Amount per scheduled payment in the smallest currency unit
first_amount_cents integer No Different amount for the first payment only
total_amount_cents integer No The total to collect. Once it’s collected the plan schedules nothing more; see When a plan reaches its total
until_further_notice boolean No true for open-ended plans with no total cap
currency string Yes ISO 4217 currency code (e.g. AUD, USD)
start_date string Yes ISO date (YYYY-MM-DD) of the first payment
first_date string No Override the first payment date (defaults to start_date)
payment_instrument_id string No Saved payment method (pm_xxx) to use. Falls back to customer default
failure_behaviour string No NEW_AT_END (default), TRY_AGAIN, DOUBLE_UP or DO_NOTHING. See Handling Failures

Either total_amount_cents or until_further_notice: true must be set.

A plan’s total can be at most $999,999.99, whether you give total_amount_cents or number_of_payments, whose total is number_of_payments × recurring_amount_cents + first_amount_cents. Creating or updating a plan over the limit is refused: a total_amount_cents over 99999999 with 400 validation_failed, and a number_of_payments that comes to more with 422 validation_failed.

The plan comes with its schedule changes: see Changing a plan’s schedule.

Terminal window
curl https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

remaining_amount_cents is what a plan has left to schedule: its total less collected_amount_cents and what is in flight (payments claimed for sending, sent, or awaiting settlement), never below 0. It is null for a plan that runs until further notice. collected_amount_cents counts refunded payments: a refund or a chargeback isn’t collected again.

A plan with a total stops scheduling payments once the payments sent for it cover the total. Its state stays active. It doesn’t wait for the money to settle. remaining_amount_cents of 0 with no next_date means nothing more will be scheduled. It doesn’t mean the last payments have been sent or settled: GET /api/v1/payment_plans/{id}/payment_intents lists the plan’s payments with their status.

The plan isn’t closed, so a payment that fails later, such as a bank debit dishonoured or returned after it was collected, is collected again under the plan’s failure behaviour, and remaining_amount_cents shows it until then. DO_NOTHING takes the failed amount off the total instead. You can still change the plan’s schedule or terms to collect more. Closing it with a closing_amount_cents of 0 makes it closed once any payments still being processed have succeeded, at once if there are none, and a payment that fails after that isn’t collected again.

A plan that isn’t collecting for a while has state suspended: it’s active, but not collecting until it’s resumed. A closing plan stays closing. Either way, pause says why, and is null while the plan is collecting:

Field Description
reason merchant (you suspended it), payment_method_failed or customer_review
since The date it began
payment_method_id For payment_method_failed, the payment method that failed
failure_code For payment_method_failed and customer_review
{
"id": "pp_0abc123def456ghi789jkl012mno",
"state": "suspended",
"pause": { "reason": "payment_method_failed", "since": "2026-10-17", "payment_method_id": "pm_0abc123", "failure_code": "5" }
}

With several open, pause describes the one that most needs action, in the order customer_review, payment_method_failed, merchant. /resume ends them all.

  • payment_method_failed: the payment method can’t be used again. A new payment method for the customer resumes the plan; see Handling failures.
  • customer_review: a bank debit code about the customer, such as a cancelled direct debit authority or a deceased customer. Every collecting plan of the customer is suspended, no email is sent, and only /resume ends it.

Payments due while you suspended a plan are skipped, and a plan with a total runs longer. Payments due while a failed payment method or a review suspended it still count, and a plan with a total collects them at the end. suspension_periods lists only the dated suspensions you set.

Terminal window
curl "https://api.elasticpay.co/api/v1/payment_plans?customer_id=cus_0abc123def456" \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Response shape

{
"data": [],
"has_more": false
}

Supported query parameters:

Parameter Description
customer_id Filter plans for a specific customer
state Filter by state (draft, active, inactive, suspended, closing, closed). suspended returns the active plans that are suspended, and active leaves them out
limit Number of results per page (default 20, max 100)
starting_after Cursor: return results after this plan ID

Use PUT to update mutable fields on a plan. Not all fields can be changed after the plan is active (e.g. frequency_type, start_date). The API will return a validation error if a field cannot be changed in the plan’s current state.

Terminal window
curl -X PUT https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"recurring_amount_cents": 5900}'

Payment plans move through states via dedicated transition endpoints.

Terminal window
# Activate (draft or inactive -> active): needs a usable payment method
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/activate \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Suspend (active -> suspended): stop collecting until you resume it
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/suspend \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Resume (suspended -> active): end every suspension
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/resume \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Deactivate (active or suspended -> inactive)
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/deactivate \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
From To Endpoint
draft, inactive active /activate
active suspended /suspend
active, suspended inactive /deactivate
suspended, or closing with a pause active, or closing /resume
active closing, then closed /close
inactive, suspended closed, with a final amount of 0 /close

/suspend stops collecting from today until you resume the plan. Payments due meanwhile are skipped: a plan with a total runs longer. /resume ends every suspension, including one for a failed payment method or a review, and plans again from today. It needs a usable payment method.

Safe to repeat, so it takes no Idempotency-Key: suspending a plan you’ve already suspended, or resuming a plan with nothing to resume, returns the plan unchanged.

Status code When
422 payment_plan_not_suspendable /suspend on a plan that isn’t active, or is closing
422 payment_method_required /resume while the plan has no usable payment method. Collect a new one from the customer first

/deactivate removes the plan’s unsent payments, and /activate plans again from that day.

POST /api/v1/payment_plans/{id}/close closes a plan for good: a closed plan can’t be reopened. It can collect one final amount first. Like the schedule changes, it needs an Idempotency-Key.

Field Type Required Description
closing_amount_cents integer Yes A final amount to collect before the plan closes: 0, or at least 200
accept_large_closing_amount boolean No Default false. Must be true when closing_amount_cents is above 0 and equal to or greater than the remaining balance

What closing does:

  • The plan’s total becomes what it has collected, including payments already sent to the bank or card network, plus closing_amount_cents.
  • The final amount is scheduled in instalments of up to 30000 cents on the plan’s next payment dates: 70000 is scheduled as 30000, 30000 and 10000. If one fails, the plan’s failure behaviour can make a later payment larger: DOUBLE_UP adds the failed amount to the next one.
  • Suspension periods that start after today are removed. One that has already started stays, and the final payments move past it. Payment adjustments stay.
  • A DO_NOTHING plan switches to NEW_AT_END, so the final amount is still collected.

The response is the plan with its schedule. Its state is closing while its final payments are still to be collected, and nothing about its schedule can be changed. A final payment that fails while the plan is closing is collected again under its failure behaviour. The plan is closed once its final payments have succeeded: a card payment when it’s approved, a bank debit once it clears, so a bank-debit plan stays closing until its final debits clear (about 2 business days after they’re sent). A closing_amount_cents of 0 adds nothing more, but payments already being processed still have to succeed. A payment that fails after the plan has closed, such as a bank debit returned late, is recorded but not collected again, and the plan’s shortfall_amount_cents shows what it didn’t collect. A plan with nothing more to collect and nothing still being processed is closed straight away.

An inactive or suspended plan can be closed with a final amount of 0, which closes it at once. To collect a final amount, activate or resume it first.

The remaining balance is the plan’s total less what it has collected and what is still being processed. A final amount above 0 that equals or exceeds it needs accept_large_closing_amount: true. If that’s the amount you mean, send true with it on the first request. Without it, the close is refused with 422 plan_command_refused:

{
"error": {
"message": "The final amount of $700.00 equals or exceeds the remaining balance of $350.00. Confirm you want to collect $700.00 before closing. To confirm, send the request again with accept_large_closing_amount: true and a new Idempotency-Key.",
"type": "invalid_request_error",
"code": "plan_command_refused",
"details": [{ "code": "invalid_input", "message": "The final amount of $700.00 equals or exceeds the remaining balance of $350.00. Confirm you want to collect $700.00 before closing. To confirm, send the request again with accept_large_closing_amount: true and a new Idempotency-Key." }]
}
}

The refusal is kept under its Idempotency-Key, so the same key with the flag changed is a different request and gets 409 idempotency_key_conflict. Send the confirmed close with a new key.

Terminal window
# Close, collecting a final $700.00, which is more than the $350.00 the plan still has to collect
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/close \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: 4c5d6e7f-close-pp_0abc123" \
-H "Content-Type: application/json" \
-d '{ "closing_amount_cents": 70000, "accept_large_closing_amount": true }'
# Close, collecting nothing more
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/close \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: 8a9b0c1d-close-pp_0abc123" \
-H "Content-Type: application/json" \
-d '{ "closing_amount_cents": 0 }'

Other refusals, each 422 plan_command_refused, include “This payment plan is already closing.”, “Final amount to collect must be $0, or at least $2.00.” and, for an inactive plan with a final amount, “This payment plan is inactive, so it can’t collect a final amount. Close it with $0, or activate it first.”

Three resources change what a plan collects and when. They are the same changes the dashboard’s plan Schedule offers (see Managing Plans):

Resource What it is Removed by
Payment adjustment A different amount on one future payment date, 0 to skip it, or an extra payment on a date with none Its date
Balance adjustment Money collected outside the plan’s debits, such as cash at reception. The dashboard calls it a non-debit payment Its id (ppba_…)
Suspension period No payments between two dates, both included Its id (ppsp_…)

A suspension period is not /suspend, which stops the whole plan with no end date until you resume it.

Fetching a plan returns all three, as payment_adjustments, balance_adjustments and suspension_periods, with what each removal needs. Changes to a closed or closing plan’s schedule are refused.

Every schedule change needs an Idempotency-Key header. Without one the request is refused with 400 missing_idempotency_key.

  • Use a new unique value, such as a UUID, for each change.
  • To retry, send the same key with the same request. You get the original result, and the change is not applied twice.
  • A key is scoped to your biller account and the API key or OAuth connection that sent it, and names one change. Reusing it on that account for any other request (another plan, another date or record, another body) is refused with 409 idempotency_key_conflict. An OAuth connection that acts for several accounts has a separate set of keys for each, so the same key sent to two accounts is two changes.
  • While a request with the key is still running, another one gets 409 idempotency_key_in_progress. Retry it with the same key.
  • Results are kept for 24 hours. A request refused before the change was attempted (a 400, a 404 or a 409) isn’t kept, so you can correct it and retry with the same key.

This is stricter than the optional key on payment intents. The idempotency comparison sets the two side by side.

Method Path Body Returns
PUT /api/v1/payment_plans/{id}/payment_adjustments/{date} amount_cents, type, change_total_amount? The plan, with its schedule
DELETE /api/v1/payment_plans/{id}/payment_adjustments/{date} — The plan, with its schedule
POST /api/v1/payment_plans/{id}/balance_adjustments amount_cents, date, description?, external_reference? 201 the balance adjustment
DELETE /api/v1/payment_plans/{id}/balance_adjustments/{balance_adjustment_id} — { "id", "deleted": true }
POST /api/v1/payment_plans/{id}/suspension_periods start_date, end_date 201 the suspension period
PUT /api/v1/payment_plans/{id}/suspension_periods/{suspension_period_id} start_date, end_date The suspension period
DELETE /api/v1/payment_plans/{id}/suspension_periods/{suspension_period_id} — { "id", "deleted": true }

Dates are YYYY-MM-DD on your account’s clock. Every endpoint is in the API reference.

type is required, with no default:

  • aligned (recommended). The plan now expects the new amount, so it isn’t behind. This is the dashboard’s default, and what skipping a payment does.
  • manual. The plan still expects the scheduled amount, so a smaller payment shows as arrears until the customer catches up.

In the dashboard this is the question “Is this a catch-up payment?”: No is aligned, Yes is manual. A request without type is refused with 400 validation_failed, naming both values.

On a plan with a total, change_total_amount decides what happens to the difference:

  • Omitted or false. The total stays. A skipped payment adds a payment at the end, a larger one finishes the plan sooner, and a date can’t exceed what the plan still has to collect up to it.
  • true. The total moves by the difference, and the plan keeps its dates.

Changing the payment on the first payment’s date changes the plan’s first payment. Removing a payment adjustment never changes the total, even when the change being removed did.

  • The date must be today or earlier.
  • A negative amount adds to what the customer owes.
  • On a plan with a total, balance adjustments can’t add up to more than the total.
  • description is optional.
  • external_reference is optional and is your own reference, such as a receipt number. It’s free-form, up to 255 characters, and not checked for uniqueness. It’s returned as you sent it, except that a blank value is treated as absent and omitted from responses.
  • Periods can’t overlap, including on the same day.
  • One can’t start before the plan, or today once a payment has been sent today.
  • Once a period has begun, its start can’t move and it can’t be removed. Its end can still change until it finishes.
Terminal window
# Skip the payment on 5 November; the plan collects it at the end
curl -X PUT https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/payment_adjustments/2026-11-05 \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: 6f1d2c3e-skip-2026-11-05" \
-H "Content-Type: application/json" \
-d '{ "amount_cents": 0, "type": "aligned" }'
# Record $50 paid in cash. If the response is lost, send this again with the same
# Idempotency-Key: the payment is recorded once.
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/balance_adjustments \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: 9a8b7c6d-cash-rcpt-0042" \
-H "Content-Type: application/json" \
-d '{ "amount_cents": 5000, "date": "2026-10-13", "description": "Cash at reception", "external_reference": "RCPT-0042" }'
# No payments over the holidays
curl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/suspension_periods \
-H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Idempotency-Key: 2e4f6a8b-holidays-2026" \
-H "Content-Type: application/json" \
-d '{ "start_date": "2026-12-20", "end_date": "2027-01-05" }'

The plan’s schedule, as a fetch returns it

{
"id": "pp_0abc123",
"payment_adjustments": [
{ "date": "2026-11-05", "type": "aligned", "amount_cents": 0 }
],
"balance_adjustments": [
{
"id": "ppba_0abc123def456ghi789jkl01",
"payment_plan_id": "pp_0abc123",
"date": "2026-10-13",
"amount_cents": 5000,
"description": "Cash at reception",
"external_reference": "RCPT-0042",
"created_at": "2026-10-13T01:02:03Z"
}
],
"suspension_periods": [
{
"id": "ppsp_0abc123def456ghi789jkl01",
"payment_plan_id": "pp_0abc123",
"start_date": "2026-12-20",
"end_date": "2027-01-05",
"created_at": "2026-10-13T01:02:04Z"
}
]
}

A payment adjustment’s type can also be failure, failure_try_again or failure_double_up. The plan’s failure behaviour writes those after a failed payment.

Status code When
400 missing_idempotency_key No Idempotency-Key header
400 validation_failed A missing or malformed field, including a missing type
404 payment_plan_not_found, balance_adjustment_not_found, suspension_period_not_found No such plan, or no such record on it
409 idempotency_key_conflict The key was already used for a different request
409 idempotency_key_in_progress A request with the key is still running. Retry with the same key
422 plan_command_refused The plan refused the change. details lists every reason, e.g. “Adjustment date must be in the future.”

Canceling one of a plan’s payment intents (POST /api/v1/payment_intents/{id}/cancel) skips that date, as the dashboard’s Skip does. The payment is removed and the plan schedules again: a plan with a total collects the amount at the end. If the payment also carries the retry of a failed payment, moved onto that date because the retry’s own date was in a suspension period, the cancel removes the retry too.

The cancel is refused with 400 invalid_status_for_cancel when the payment:

  • is dated today or earlier on your account’s clock;
  • falls inside one of the plan’s suspension periods;
  • belongs to a plan that is closing or closed;
  • has been claimed or sent for processing.

A refused cancel changes nothing and sends no payment_intent.canceled event. To change what a date collects rather than skip it, use a payment adjustment.

Plans send no webhook events of their own. Each scheduled payment is a payment intent, whose events you can subscribe to:

Event When
payment_intent.succeeded A scheduled payment is collected
payment_intent.failed A scheduled payment fails
Operation Required scope
Create / update / state transitions / schedule changes payment_plans:write
Fetch / list payment_plans:read

Secret keys (sk_xxx) have both scopes by default.