Payment Plans API
Overview
Section titled “Overview”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.
Create a payment plan
Section titled “Create a payment plan”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 }'const res = await fetch("https://api.elasticpay.co/api/v1/payment_plans", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ 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, }),});const plan = await res.json();import requests
res = requests.post( "https://api.elasticpay.co/api/v1/payment_plans", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={ "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, },)plan = res.json()<?phpuse GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ],]);
$res = $client->post('/api/v1/payment_plans', [ 'json' => [ '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, ],]);$plan = json_decode($res->getBody(), true);require 'faraday'require 'json'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'conn.headers['Content-Type'] = 'application/json'
res = conn.post('/api/v1/payment_plans') do |req| req.body = { 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 }endplan = res.bodyusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
var res = await http.PostAsJsonAsync("/api/v1/payment_plans", new { 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,});var plan = await res.Content.ReadFromJsonAsync<JsonElement>();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"}Create parameters
Section titled “Create parameters”| 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.
Fetch a payment plan
Section titled “Fetch a payment plan”The plan comes with its schedule changes: see Changing a plan’s schedule.
curl https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } });const plan = await res.json();import requests
res = requests.get( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},)plan = res.json()<?phpuse GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ],]);
$res = $client->get('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno');$plan = json_decode($res->getBody(), true);require 'faraday'require 'json'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'conn.headers['Content-Type'] = 'application/json'
res = conn.get('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno')plan = res.bodyusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
var plan = await http.GetFromJsonAsync<JsonElement>( "/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno");When a plan reaches its total
Section titled “When a plan reaches its total”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.
Suspended plans
Section titled “Suspended plans”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/resumeends 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.
List payment plans
Section titled “List payment plans”curl "https://api.elasticpay.co/api/v1/payment_plans?customer_id=cus_0abc123def456" \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans?customer_id=cus_0abc123def456", { headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } });const { data, has_more } = await res.json();import requests
res = requests.get( "https://api.elasticpay.co/api/v1/payment_plans", headers={"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}, params={"customer_id": "cus_0abc123def456"},)data = res.json()<?phpuse GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ],]);
$res = $client->get('/api/v1/payment_plans', [ 'query' => ['customer_id' => 'cus_0abc123def456'],]);$data = json_decode($res->getBody(), true);require 'faraday'require 'json'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'conn.headers['Content-Type'] = 'application/json'
res = conn.get('/api/v1/payment_plans') do |req| req.params['customer_id'] = 'cus_0abc123def456'enddata = res.bodyusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
var data = await http.GetFromJsonAsync<JsonElement>( "/api/v1/payment_plans?customer_id=cus_0abc123def456");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 |
Update a payment plan
Section titled “Update a payment plan”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.
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}'const res = await fetch( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", { method: "PUT", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, body: JSON.stringify({ recurring_amount_cents: 5900 }), });const updated = await res.json();import requests
res = requests.put( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Content-Type": "application/json", }, json={"recurring_amount_cents": 5900},)updated = res.json()<?phpuse GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ],]);
$res = $client->put('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno', [ 'json' => ['recurring_amount_cents' => 5900],]);$updated = json_decode($res->getBody(), true);require 'faraday'require 'json'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'conn.headers['Content-Type'] = 'application/json'
res = conn.put('/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno') do |req| req.body = { recurring_amount_cents: 5900 }endupdated = res.bodyusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
var res = await http.PutAsJsonAsync( "/api/v1/payment_plans/pp_0abc123def456ghi789jkl012mno", new { recurring_amount_cents = 5900 });var updated = await res.Content.ReadFromJsonAsync<JsonElement>();State transitions
Section titled “State transitions”Payment plans move through states via dedicated transition endpoints.
# Activate (draft or inactive -> active): needs a usable payment methodcurl -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 itcurl -X POST https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/suspend \ -H "Authorization: Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Resume (suspended -> active): end every suspensioncurl -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"const AUTH = "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";const BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123";
async function transition(action) { const res = await fetch(`${BASE}/${action}`, { method: "POST", headers: { "Authorization": AUTH }, }); return res.json();}
await transition("activate"); // draft -> activeawait transition("suspend"); // active -> suspendedawait transition("resume"); // suspended -> activeawait transition("deactivate"); // active -> inactiveimport requests
AUTH = {"Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123"
def transition(action): return requests.post(f"{BASE}/{action}", headers=AUTH).json()
transition("activate") # draft -> activetransition("suspend") # active -> suspendedtransition("resume") # suspended -> activetransition("deactivate") # active -> inactive<?phpuse GuzzleHttp\Client;
$client = new Client([ 'base_uri' => 'https://api.elasticpay.co', 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Content-Type' => 'application/json', ],]);
$planId = 'pp_0abc123';foreach (['activate', 'suspend', 'resume', 'deactivate'] as $action) { $client->post("/api/v1/payment_plans/{$planId}/{$action}");}require 'faraday'require 'json'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'conn.headers['Content-Type'] = 'application/json'
plan_id = 'pp_0abc123'%w[activate suspend resume deactivate].each do |action| conn.post("/api/v1/payment_plans/#{plan_id}/#{action}")endusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
const string planId = "pp_0abc123";foreach (var action in new[] { "activate", "suspend", "resume", "deactivate" }){ await http.PostAsync($"/api/v1/payment_plans/{planId}/{action}", null);}Valid transitions
Section titled “Valid transitions”| 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 and resume
Section titled “Suspend and resume”/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.
Close a plan
Section titled “Close a plan”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_UPadds 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_NOTHINGplan switches toNEW_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.
Confirming a large final amount
Section titled “Confirming a large final amount”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.
# Close, collecting a final $700.00, which is more than the $350.00 the plan still has to collectcurl -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 morecurl -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 }'const res = await fetch("https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/close", { method: "POST", headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Idempotency-Key": crypto.randomUUID(), "Content-Type": "application/json", }, body: JSON.stringify({ closing_amount_cents: 70000, accept_large_closing_amount: true }),});const plan = await res.json(); // plan.state is "closing" until the final payments succeedimport uuidimport requests
plan = requests.post( "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123/close", headers={ "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Idempotency-Key": str(uuid.uuid4()), }, json={"closing_amount_cents": 70000, "accept_large_closing_amount": True},).json() # plan["state"] is "closing" until the final payments succeed<?phpuse GuzzleHttp\Client;use Ramsey\Uuid\Uuid;
$client = new Client(['base_uri' => 'https://api.elasticpay.co']);$response = $client->post('/api/v1/payment_plans/pp_0abc123/close', [ 'headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Idempotency-Key' => Uuid::uuid4()->toString(), ], 'json' => ['closing_amount_cents' => 70000, 'accept_large_closing_amount' => true],]);$plan = json_decode($response->getBody(), true); // $plan['state'] is "closing" until the final payments succeedrequire 'faraday'require 'securerandom'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendresponse = conn.post('/api/v1/payment_plans/pp_0abc123/close', { closing_amount_cents: 70_000, accept_large_closing_amount: true }, 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Idempotency-Key' => SecureRandom.uuid)plan = response.body # plan['state'] is "closing" until the final payments succeedusing System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/payment_plans/pp_0abc123/close"){ Content = JsonContent.Create(new { closing_amount_cents = 70000, accept_large_closing_amount = true }),};request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());var response = await http.SendAsync(request); // the plan's state is "closing" until the final payments succeedOther 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.”
Changing a plan’s schedule
Section titled “Changing a plan’s schedule”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.
The Idempotency-Key is required
Section titled “The Idempotency-Key is required”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, a404or a409) 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.
Endpoints
Section titled “Endpoints”| 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.
Changing a payment: aligned or manual
Section titled “Changing a payment: aligned or manual”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.
Balance adjustments
Section titled “Balance adjustments”- 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.
descriptionis optional.external_referenceis 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.
Suspension periods
Section titled “Suspension periods”- 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.
# Skip the payment on 5 November; the plan collects it at the endcurl -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 holidayscurl -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" }'const BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123";
// The caller owns the key: make one per change, before the first attempt, and reuse it to retry.async function change(method, path, idempotencyKey, body) { const res = await fetch(`${BASE}${path}`, { method, headers: { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Idempotency-Key": idempotencyKey, "Content-Type": "application/json", }, body: body && JSON.stringify(body), }); return res.json();}
await change("PUT", "/payment_adjustments/2026-11-05", crypto.randomUUID(), { amount_cents: 0, type: "aligned" });
const cashKey = crypto.randomUUID();const cashPayment = { amount_cents: 5000, date: "2026-10-13", description: "Cash at reception", external_reference: "RCPT-0042",};let cash;try { cash = await change("POST", "/balance_adjustments", cashKey, cashPayment);} catch { // The response was lost: retry with the same key, and the payment is recorded once. cash = await change("POST", "/balance_adjustments", cashKey, cashPayment);}
await change("POST", "/suspension_periods", crypto.randomUUID(), { start_date: "2026-12-20", end_date: "2027-01-05" });await change("DELETE", `/balance_adjustments/${cash.id}`, crypto.randomUUID());import uuidimport requests
BASE = "https://api.elasticpay.co/api/v1/payment_plans/pp_0abc123"
# The caller owns the key: make one per change, before the first attempt, and reuse it to retry.def change(method, path, idempotency_key, body=None): headers = { "Authorization": "Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Idempotency-Key": idempotency_key, } return requests.request(method, f"{BASE}{path}", headers=headers, json=body).json()
change("PUT", "/payment_adjustments/2026-11-05", str(uuid.uuid4()), {"amount_cents": 0, "type": "aligned"})
cash_key = str(uuid.uuid4())cash_payment = { "amount_cents": 5000, "date": "2026-10-13", "description": "Cash at reception", "external_reference": "RCPT-0042",}try: cash = change("POST", "/balance_adjustments", cash_key, cash_payment)except requests.RequestException: # The response was lost: retry with the same key, and the payment is recorded once. cash = change("POST", "/balance_adjustments", cash_key, cash_payment)
change("POST", "/suspension_periods", str(uuid.uuid4()), {"start_date": "2026-12-20", "end_date": "2027-01-05"})change("DELETE", f"/balance_adjustments/{cash['id']}", str(uuid.uuid4()))<?phpuse GuzzleHttp\Client;use GuzzleHttp\Exception\TransferException;use Ramsey\Uuid\Uuid;
$client = new Client(['base_uri' => 'https://api.elasticpay.co']);$base = '/api/v1/payment_plans/pp_0abc123';
// The caller owns the key: make one per change, before the first attempt, and reuse it to retry.function change(Client $client, string $method, string $path, string $idempotencyKey, ?array $body = null): array { $options = ['headers' => [ 'Authorization' => 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'Idempotency-Key' => $idempotencyKey, ]]; if ($body !== null) $options['json'] = $body; return json_decode($client->request($method, $path, $options)->getBody(), true);}
change($client, 'PUT', "$base/payment_adjustments/2026-11-05", Uuid::uuid4()->toString(), ['amount_cents' => 0, 'type' => 'aligned']);
$cashKey = Uuid::uuid4()->toString();$cashPayment = [ 'amount_cents' => 5000, 'date' => '2026-10-13', 'description' => 'Cash at reception', 'external_reference' => 'RCPT-0042',];try { $cash = change($client, 'POST', "$base/balance_adjustments", $cashKey, $cashPayment);} catch (TransferException $e) { // The response was lost: retry with the same key, and the payment is recorded once. $cash = change($client, 'POST', "$base/balance_adjustments", $cashKey, $cashPayment);}
change($client, 'POST', "$base/suspension_periods", Uuid::uuid4()->toString(), ['start_date' => '2026-12-20', 'end_date' => '2027-01-05']);change($client, 'DELETE', "$base/balance_adjustments/{$cash['id']}", Uuid::uuid4()->toString());require 'faraday'require 'json'require 'securerandom'
conn = Faraday.new('https://api.elasticpay.co') do |f| f.request :json f.response :jsonendconn.headers['Authorization'] = 'Bearer sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'base = '/api/v1/payment_plans/pp_0abc123'
# The caller owns the key: make one per change, before the first attempt, and reuse it to retry.change = lambda do |method, path, idempotency_key, body = nil| conn.run_request(method, "#{base}#{path}", body, 'Idempotency-Key' => idempotency_key).bodyend
change.call(:put, '/payment_adjustments/2026-11-05', SecureRandom.uuid, { amount_cents: 0, type: 'aligned' })
cash_key = SecureRandom.uuidcash_payment = { amount_cents: 5000, date: '2026-10-13', description: 'Cash at reception', external_reference: 'RCPT-0042' }cash = begin change.call(:post, '/balance_adjustments', cash_key, cash_payment)rescue Faraday::Error # The response was lost: retry with the same key, and the payment is recorded once. change.call(:post, '/balance_adjustments', cash_key, cash_payment)end
change.call(:post, '/suspension_periods', SecureRandom.uuid, { start_date: '2026-12-20', end_date: '2027-01-05' })change.call(:delete, "/balance_adjustments/#{cash['id']}", SecureRandom.uuid)using System.Net.Http;using System.Net.Http.Headers;using System.Net.Http.Json;using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.elasticpay.co") };http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sk_sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");const string Base = "/api/v1/payment_plans/pp_0abc123";
// The caller owns the key: make one per change, before the first attempt, and reuse it to retry.async Task<JsonElement> Change(HttpMethod method, string path, string idempotencyKey, object? body = null){ var request = new HttpRequestMessage(method, Base + path); request.Headers.Add("Idempotency-Key", idempotencyKey); if (body is not null) request.Content = JsonContent.Create(body); var response = await http.SendAsync(request); return await response.Content.ReadFromJsonAsync<JsonElement>();}
await Change(HttpMethod.Put, "/payment_adjustments/2026-11-05", Guid.NewGuid().ToString(), new { amount_cents = 0, type = "aligned" });
var cashKey = Guid.NewGuid().ToString();var cashPayment = new { amount_cents = 5000, date = "2026-10-13", description = "Cash at reception", external_reference = "RCPT-0042" };JsonElement cash;try{ cash = await Change(HttpMethod.Post, "/balance_adjustments", cashKey, cashPayment);}catch (HttpRequestException){ // The response was lost: retry with the same key, and the payment is recorded once. cash = await Change(HttpMethod.Post, "/balance_adjustments", cashKey, cashPayment);}
await Change(HttpMethod.Post, "/suspension_periods", Guid.NewGuid().ToString(), new { start_date = "2026-12-20", end_date = "2027-01-05" });await Change(HttpMethod.Delete, $"/balance_adjustments/{cash.GetProperty("id").GetString()}", Guid.NewGuid().ToString());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.
Errors
Section titled “Errors”| 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 a plan’s payment
Section titled “Canceling a plan’s payment”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.
Webhook events
Section titled “Webhook events”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 |
Required permissions
Section titled “Required permissions”| 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.